From cd3bbc4e64fc2ffabbfa464cdc6cf98ce5fa1ad0 Mon Sep 17 00:00:00 2001 From: Mark Liversedge Date: Sat, 17 Jul 2010 14:33:39 +0100 Subject: [PATCH] Ride editor and tools A new tab 'Editor' for manually editing ride file data points and associated menu options under 'Tools' for fixing spikes, gaps, GPS errors and adjusting torque values. A revert to saved ride option is also included to 'undo' all changes. The ride editor supports undo/redo as well as cut and paste and "paste special" (to append points or swap columns/overwrite selected data series). The editor also supports search and will automatically highlight anomalous data. When a file is saved, the changes are recorded in a new metadata special field called "Change History" which can be added as a Textbox in the metadata config. The data processors can be run manually or automatically when a ride is opened - these are configured on the ride data tab in the config pane. Significant changes have been introduced in the codebase, the most significant of which are; a RideFileCommand class for modifying ride data has been introduced (as a member of RideFile) and the RideItem class is now a QObject as well as QTreeWidgetItem to enable signalling. The Ride Editor uses a RideFileTableModel that can be re-used in other parts of the code. LTMoutliers class has been introduced in support of anomaly detection in the editor (which highlights anomalies with a wiggly red line). Fixes #103. --- src/AllPlotWindow.cpp | 2 - src/DataProcessor.cpp | 118 ++ src/DataProcessor.h | 114 ++ src/FixGPS.cpp | 127 ++ src/FixGaps.cpp | 248 +++ src/FixSpikes.cpp | 198 +++ src/FixTorque.cpp | 150 ++ src/LTMOutliers.cpp | 93 ++ src/LTMOutliers.h | 56 + src/MainWindow.cpp | 77 +- src/MainWindow.h | 10 + src/Pages.cpp | 80 + src/Pages.h | 26 + src/RideEditor.cpp | 2241 +++++++++++++++++++++++++++ src/RideEditor.h | 271 ++++ src/RideFile.cpp | 243 ++- src/RideFile.h | 113 +- src/RideFileCommand.cpp | 410 +++++ src/RideFileCommand.h | 194 +++ src/RideFileTableModel.cpp | 380 +++++ src/RideFileTableModel.h | 96 ++ src/RideItem.cpp | 39 +- src/RideItem.h | 23 +- src/RideMetadata.cpp | 8 +- src/SaveDialogs.cpp | 10 +- src/Settings.h | 7 + src/SpecialFields.cpp | 7 + src/WeeklySummaryWindow.cpp | 4 - src/application.qrc | 9 + src/images/toolbar/close-icon.png | Bin 0 -> 1979 bytes src/images/toolbar/copy.png | Bin 0 -> 2130 bytes src/images/toolbar/cut.png | Bin 0 -> 1890 bytes src/images/toolbar/paste.png | Bin 0 -> 1844 bytes src/images/toolbar/redo.png | Bin 0 -> 1502 bytes src/images/toolbar/refresh.png | Bin 0 -> 3052 bytes src/images/toolbar/save.png | Bin 0 -> 1412 bytes src/images/toolbar/search.png | Bin 0 -> 2790 bytes src/images/toolbar/splash green.png | Bin 0 -> 3401 bytes src/images/toolbar/star.png | Bin 0 -> 2723 bytes src/images/toolbar/undo.png | Bin 0 -> 1532 bytes src/src.pro | 14 + 41 files changed, 5314 insertions(+), 54 deletions(-) create mode 100644 src/DataProcessor.cpp create mode 100644 src/DataProcessor.h create mode 100644 src/FixGPS.cpp create mode 100644 src/FixGaps.cpp create mode 100644 src/FixSpikes.cpp create mode 100644 src/FixTorque.cpp create mode 100644 src/LTMOutliers.cpp create mode 100644 src/LTMOutliers.h create mode 100644 src/RideEditor.cpp create mode 100644 src/RideEditor.h create mode 100644 src/RideFileCommand.cpp create mode 100644 src/RideFileCommand.h create mode 100644 src/RideFileTableModel.cpp create mode 100644 src/RideFileTableModel.h create mode 100644 src/images/toolbar/close-icon.png create mode 100644 src/images/toolbar/copy.png create mode 100644 src/images/toolbar/cut.png create mode 100644 src/images/toolbar/paste.png create mode 100644 src/images/toolbar/redo.png create mode 100644 src/images/toolbar/refresh.png create mode 100644 src/images/toolbar/save.png create mode 100644 src/images/toolbar/search.png create mode 100644 src/images/toolbar/splash green.png create mode 100644 src/images/toolbar/star.png create mode 100644 src/images/toolbar/undo.png diff --git a/src/AllPlotWindow.cpp b/src/AllPlotWindow.cpp index 3ea37d0bb..df063a6bd 100644 --- a/src/AllPlotWindow.cpp +++ b/src/AllPlotWindow.cpp @@ -453,8 +453,6 @@ AllPlotWindow::setEndSelection(AllPlot* plot, int xPosition, bool newInterval, Q //allMarker3->setZ(-1000.0); allMarker3->show(); - RideFile tmpRide = RideFile(); - QTreeWidgetItem *which = mainWindow->rideItem(); RideItem *ride = (RideItem*)which; diff --git a/src/DataProcessor.cpp b/src/DataProcessor.cpp new file mode 100644 index 000000000..30c2660dc --- /dev/null +++ b/src/DataProcessor.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2010 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 "DataProcessor.h" +#include "MainWindow.h" +#include "AllPlot.h" +#include "Settings.h" +#include "Units.h" +#include + +DataProcessorFactory *DataProcessorFactory::instance_; + +DataProcessorFactory &DataProcessorFactory::instance() +{ + if (!instance_) instance_ = new DataProcessorFactory(); + return *instance_; +} + +bool +DataProcessorFactory::registerProcessor(QString name, DataProcessor *processor) +{ + assert(!processors.contains(name)); + processors.insert(name, processor); + return true; +} + +bool +DataProcessorFactory::autoProcess(RideFile *ride) +{ + boost::shared_ptr settings = GetApplicationSettings(); + bool changed = false; + + // run through the processors and execute them! + QMapIterator i(processors); + i.toFront(); + while (i.hasNext()) { + i.next(); + QString configsetting = QString("dp/%1/apply").arg(i.key()); + if (settings->value(configsetting, "Manual").toString() == "Auto") + i.value()->postProcess(ride); + } + + return changed; +} + +ManualDataProcessorDialog::ManualDataProcessorDialog(MainWindow *main, QString name, RideItem *ride) : main(main), ride(ride) +{ + setAttribute(Qt::WA_DeleteOnClose); + setWindowTitle(name); + QVBoxLayout *mainLayout = new QVBoxLayout(this); + + // find our processor + const DataProcessorFactory &factory = DataProcessorFactory::instance(); + QMap processors = factory.getProcessors(); + processor = processors.value(name, NULL); + + if (processor == NULL) reject(); + + QFont font; + font.setWeight(QFont::Black); + QLabel *configLabel = new QLabel(tr("Settings"), this); + configLabel->setFont(font); + QLabel *explainLabel = new QLabel(tr("Description"), this); + explainLabel->setFont(font); + + config = processor->processorConfig(this); + config->readConfig(); + explain = new QTextEdit(this); + explain->setText(config->explain()); + explain->setReadOnly(true); + + mainLayout->addWidget(configLabel); + mainLayout->addWidget(config); + mainLayout->addWidget(explainLabel); + mainLayout->addWidget(explain); + + ok = new QPushButton(tr("OK"), this); + cancel = new QPushButton(tr("Cancel"), this); + QHBoxLayout *buttons = new QHBoxLayout(); + buttons->addStretch(); + buttons->addWidget(cancel); + buttons->addWidget(ok); + + mainLayout->addLayout(buttons); + + connect(ok, SIGNAL(clicked()), this, SLOT(okClicked())); + connect(cancel, SIGNAL(clicked()), this, SLOT(cancelClicked())); +} + +void +ManualDataProcessorDialog::okClicked() +{ + if (processor->postProcess((RideFile *)ride->ride(), config) == true) { + main->notifyRideSelected(); // XXX to remain compatible with rest of GC for now + } + accept(); +} + +void +ManualDataProcessorDialog::cancelClicked() +{ + reject(); +} diff --git a/src/DataProcessor.h b/src/DataProcessor.h new file mode 100644 index 000000000..ead80461c --- /dev/null +++ b/src/DataProcessor.h @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2010 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 + */ + +#ifndef _DataProcessor_h +#define _DataProcessor_h + +#include "RideFile.h" +#include "RideFileCommand.h" +#include "RideItem.h" +#include +#include +#include +#include +#include +#include + +// This file defines four classes: +// +// DataProcessorConfig is a base QWidget that must be supplied by the +// DataProcessor to enable the user to configure its options +// +// DataProcessor is an abstract base class for function-objects that take a +// rideFile and manipulate it. Examples include fixing gaps in recording or +// creating the .notes or .cpi file +// +// DataProcessorFactory is a singleton that maintains a mapping of +// all DataProcessor objects that can be applied to rideFiles +// +// ManualDataProcessorDialog is a dialog box to manually execute a +// dataprocessor on the current ride and is called from the mainWindow menus +// + + +#include + +// every data processor must supply a configuration Widget +// when its processorConfig member is called +class DataProcessorConfig : public QWidget +{ + Q_OBJECT + + public: + DataProcessorConfig(QWidget *parent=0) : QWidget(parent) {} + virtual ~DataProcessorConfig() {} + virtual void readConfig() = 0; + virtual void saveConfig() = 0; + virtual QString explain() = 0; +}; + +// the data processor abstract base class +class DataProcessor +{ + public: + DataProcessor() {} + virtual ~DataProcessor() {} + virtual bool postProcess(RideFile *, DataProcessorConfig*settings=0) = 0; + virtual DataProcessorConfig *processorConfig(QWidget *parent) = 0; +}; + +// all data processors +class DataProcessorFactory { + + private: + + static DataProcessorFactory *instance_; + QMap processors; + DataProcessorFactory() {} + + public: + + static DataProcessorFactory &instance(); + + bool registerProcessor(QString name, DataProcessor *processor); + QMap getProcessors() const { return processors; } + bool autoProcess(RideFile *); // run auto processes (after open rideFile) +}; + +class MainWindow; +class ManualDataProcessorDialog : public QDialog +{ + Q_OBJECT + + public: + ManualDataProcessorDialog(MainWindow *, QString, RideItem *); + + private slots: + void cancelClicked(); + void okClicked(); + + private: + + MainWindow *main; + RideItem *ride; + DataProcessor *processor; + DataProcessorConfig *config; + QTextEdit *explain; + QPushButton *ok, *cancel; +}; +#endif // _DataProcessor_h diff --git a/src/FixGPS.cpp b/src/FixGPS.cpp new file mode 100644 index 000000000..930035fff --- /dev/null +++ b/src/FixGPS.cpp @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2010 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 "DataProcessor.h" +#include "Settings.h" +#include "Units.h" +#include +#include + +#define tr(s) QObject::tr(s) + +// Config widget used by the Preferences/Options config panes +class FixGPS; +class FixGPSConfig : public DataProcessorConfig +{ + friend class ::FixGPS; + protected: + public: + // there is no config + FixGPSConfig(QWidget *parent) : DataProcessorConfig(parent) {} + + QString explain() { + return(QString(tr("Remove GPS errors and interpolate positional " + "data where the GPS device did not record any data, " + "or the data that was recorded is invalid."))); + } + + void readConfig() {} + void saveConfig() {} +}; + + +// RideFile Dataprocessor -- used to handle gaps in recording +// by inserting interpolated/zero samples +// to ensure dataPoints are contiguous in time +// +class FixGPS : public DataProcessor { + + public: + FixGPS() {} + ~FixGPS() {} + + // the processor + bool postProcess(RideFile *, DataProcessorConfig* config); + + // the config widget + DataProcessorConfig* processorConfig(QWidget *parent) { + return new FixGPSConfig(parent); + } +}; + +static bool fixGPSAdded = DataProcessorFactory::instance().registerProcessor(QString(tr("Fix GPS errors")), new FixGPS()); + +bool +FixGPS::postProcess(RideFile *ride, DataProcessorConfig *) +{ + // ignore null or files without GPS data + if (!ride || ride->areDataPresent()->lat == false || ride->areDataPresent()->lon == false) + return false; + + int errors=0; + + ride->command->startLUW("Fix GPS Errors"); + + int lastgood = -1; // where did we last have decent GPS data? + for (int i=0; idataPoints().count(); i++) { + // is this one decent? + if (ride->dataPoints()[i]->lat && ride->dataPoints()[i]->lat >= double(-90) && ride->dataPoints()[i]->lat <= double(90) && + ride->dataPoints()[i]->lon && ride->dataPoints()[i]->lon >= double(-180) && ride->dataPoints()[i]->lon <= double(180)) { + + if (lastgood != -1 && (lastgood+1) != i) { + // interpolate from last good to here + // then set last good to here + double deltaLat = (ride->dataPoints()[i]->lat - ride->dataPoints()[lastgood]->lat) / double(i-lastgood); + double deltaLon = (ride->dataPoints()[i]->lon - ride->dataPoints()[lastgood]->lon) / double(i-lastgood); + for (int j=lastgood+1; jcommand->setPointValue(j, RideFile::lat, ride->dataPoints()[lastgood]->lat + (double(j-lastgood)*deltaLat)); + ride->command->setPointValue(j, RideFile::lon, ride->dataPoints()[lastgood]->lon + (double(j-lastgood)*deltaLon)); + errors++; + } + } else if (lastgood == -1) { + // fill to front + for (int j=0; jcommand->setPointValue(j, RideFile::lat, ride->dataPoints()[i]->lat); + ride->command->setPointValue(j, RideFile::lon, ride->dataPoints()[i]->lon); + errors++; + } + } + lastgood = i; + } + } + + // fill to end... + if (lastgood != -1 && lastgood != (ride->dataPoints().count()-1)) { + // fill from lastgood to end with lastgood + for (int j=lastgood+1; jdataPoints().count(); j++) { + ride->command->setPointValue(j, RideFile::lat, ride->dataPoints()[lastgood]->lat); + ride->command->setPointValue(j, RideFile::lon, ride->dataPoints()[lastgood]->lon); + errors++; + } + } else { + // they are all bad!! + // XXX do nothing? + } + ride->command->endLUW(); + + if (errors) { + ride->setTag("GPS errors", QString("%1").arg(errors)); + return true; + } else + return false; +} diff --git a/src/FixGaps.cpp b/src/FixGaps.cpp new file mode 100644 index 000000000..06e799f42 --- /dev/null +++ b/src/FixGaps.cpp @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2010 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 "DataProcessor.h" +#include "Settings.h" +#include "Units.h" +#include +#include + +#define tr(s) QObject::tr(s) + +// Config widget used by the Preferences/Options config panes +class FixGaps; +class FixGapsConfig : public DataProcessorConfig +{ + friend class ::FixGaps; + protected: + QHBoxLayout *layout; + QLabel *toleranceLabel, *beerandburritoLabel; + QDoubleSpinBox *tolerance, + *beerandburrito; + + public: + FixGapsConfig(QWidget *parent) : DataProcessorConfig(parent) { + + layout = new QHBoxLayout(this); + + layout->setContentsMargins(0,0,0,0); + setContentsMargins(0,0,0,0); + + toleranceLabel = new QLabel(tr("Tolerance")); + beerandburritoLabel = new QLabel(tr("Stop")); + + tolerance = new QDoubleSpinBox(); + tolerance->setMaximum(99.99); + tolerance->setMinimum(0); + tolerance->setSingleStep(0.1); + + beerandburrito = new QDoubleSpinBox(); + beerandburrito->setMaximum(99999.99); + beerandburrito->setMinimum(0); + beerandburrito->setSingleStep(0.1); + + layout->addWidget(toleranceLabel); + layout->addWidget(tolerance); + layout->addWidget(beerandburritoLabel); + layout->addWidget(beerandburrito); + layout->addStretch(); + } + + //~FixGapsConfig() {} // deliberately not declared since Qt will delete + // the widget and its children when the config pane is deleted + + QString explain() { + return(QString(tr("Many devices, especially wireless devices, will " + "drop connections to the bike computer. This leads " + "to lost samples in the resulting data, or so-called " + "drops in recording.\n\n" + "In order to calculate peak powers and averages, it " + "is very helpful to remove these gaps, and either " + "smooth the data where it is missing or just " + "replace with zero value samples\n\n" + "This function performs this task, taking two " + "parameters;\n\n" + "tolerance - this defines the minimum size of a " + "recording gap (in seconds) that will be processed. " + "any gap shorter than this will not be affected.\n\n" + "stop - this defines the maximum size of " + "gap (in seconds) that will have a smoothing algorithm " + "applied. Where a gap is shorter than this value it will " + "be filled with values interpolated from the values " + "recorded before and after the gap. If it is longer " + "than this value, it will be filled with zero values."))); + } + + void readConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + double tol = settings->value(GC_DPFG_TOLERANCE, "1.0").toDouble(); + double stop = settings->value(GC_DPFG_STOP, "1.0").toDouble(); + tolerance->setValue(tol); + beerandburrito->setValue(stop); + } + + void saveConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + settings->setValue(GC_DPFG_TOLERANCE, tolerance->value()); + settings->setValue(GC_DPFG_STOP, beerandburrito->value()); + } +}; + + +// RideFile Dataprocessor -- used to handle gaps in recording +// by inserting interpolated/zero samples +// to ensure dataPoints are contiguous in time +// +class FixGaps : public DataProcessor { + + public: + FixGaps() {} + ~FixGaps() {} + + // the processor + bool postProcess(RideFile *, DataProcessorConfig* config); + + // the config widget + DataProcessorConfig* processorConfig(QWidget *parent) { + return new FixGapsConfig(parent); + } +}; + +static bool fixGapsAdded = DataProcessorFactory::instance().registerProcessor(QString(tr("Fix Gaps in Recording")), new FixGaps()); + +bool +FixGaps::postProcess(RideFile *ride, DataProcessorConfig *config=0) +{ + // get settings + double tolerance, stop; + if (config == NULL) { // being called automatically + boost::shared_ptr settings = GetApplicationSettings(); + tolerance = settings->value(GC_DPFG_TOLERANCE, "1.0").toDouble(); + stop = settings->value(GC_DPFG_STOP, "1.0").toDouble(); + } else { // being called manually + tolerance = ((FixGapsConfig*)(config))->tolerance->value(); + stop = ((FixGapsConfig*)(config))->beerandburrito->value(); + } + + // if the number of duration / number of samples + // equals the recording interval then we don't need + // to post-process for gaps + // XXX commented out since it is not always true and + // is purely to improve performance + //if ((ride->recIntSecs() + ride->dataPoints()[ride->dataPoints().count()-1]->secs - + // ride->dataPoints()[0]->secs) / (double) ride->dataPoints().count() == ride->recIntSecs()) + // return false; + + // Additionally, If there are less than 2 dataPoints then there + // is no way of post processing anyway (e.g. manual workouts) + if (ride->dataPoints().count() < 2) return false; + + // OK, so there are probably some gaps, lets post process them + RideFilePoint *last = NULL; + int dropouts = 0; + double dropouttime = 0.0; + + // put it all in a LUW + ride->command->startLUW("Fix Gaps in Recording"); + + for (int position = 0; position < ride->dataPoints().count(); position++) { + RideFilePoint *point = ride->dataPoints()[position]; + + if (last == NULL) last = point; + else { + double gap = point->secs - last->secs - ride->recIntSecs(); + + // if we have gps and we moved, then this isn't a stop + bool stationary = ((last->lat || last->lon) && (point->lat || point->lon)) // gps is present + && last->lat == point->lat && last->lon == point->lon; + + // moved for less than 30 seconds ... interpolate + if (!stationary && gap > tolerance && gap < stop) { + + // what's needed? + dropouts++; + dropouttime += gap; + + int count = gap/ride->recIntSecs(); + double hrdelta = (point->hr - last->hr) / (double) count; + double pwrdelta = (point->watts - last->watts) / (double) count; + double kphdelta = (point->kph - last->kph) / (double) count; + double kmdelta = (point->km - last->km) / (double) count; + double caddelta = (point->cad - last->cad) / (double) count; + double altdelta = (point->alt - last->alt) / (double) count; + double nmdelta = (point->nm - last->nm) / (double) count; + double londelta = (point->lon - last->lon) / (double) count; + double latdelta = (point->lat - last->lat) / (double) count; + double hwdelta = (point->headwind - last->headwind) / (double) count; + + // add the points + for(int i=0; isecs+((i+1)*ride->recIntSecs()), + last->cad+((i+1)*caddelta), + last->hr + ((i+1)*hrdelta), + last->km + ((i+1)*kmdelta), + last->kph + ((i+1)*kphdelta), + last->nm + ((i+1)*nmdelta), + last->watts + ((i+1)*pwrdelta), + last->alt + ((i+1)*altdelta), + last->lon + ((i+1)*londelta), + last->lat + ((i+1)*latdelta), + last->headwind + ((i+1)*hwdelta), + last->interval); + ride->command->insertPoint(position++, add); + } + + // stationary or greater than 30 seconds... fill with zeroes + } else if (gap > stop) { + + dropouts++; + dropouttime += gap; + + int count = gap/ride->recIntSecs(); + double kmdelta = (point->km - last->km) / (double) count; + + // add zero value points + for(int i=0; isecs+((i+1)*ride->recIntSecs()), + 0, + 0, + last->km + ((i+1)*kmdelta), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + last->interval); + ride->command->insertPoint(position++, add); + } + } + } + last = point; + } + + // end the Logical unit of work here + ride->command->endLUW(); + + ride->setTag("Dropouts", QString("%1").arg(dropouts)); + ride->setTag("Dropout Time", QString("%1").arg(dropouttime)); + + if (dropouts) return true; + else return false; +} diff --git a/src/FixSpikes.cpp b/src/FixSpikes.cpp new file mode 100644 index 000000000..bc166a40f --- /dev/null +++ b/src/FixSpikes.cpp @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2010 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 "DataProcessor.h" +#include "LTMOutliers.h" +#include "Settings.h" +#include "Units.h" +#include +#include + +#define tr(s) QObject::tr(s) + +// Config widget used by the Preferences/Options config panes +class FixSpikes; +class FixSpikesConfig : public DataProcessorConfig +{ + friend class ::FixSpikes; + protected: + QHBoxLayout *layout; + QLabel *maxLabel, *varianceLabel; + QDoubleSpinBox *max, + *variance; + + public: + FixSpikesConfig(QWidget *parent) : DataProcessorConfig(parent) { + + layout = new QHBoxLayout(this); + + layout->setContentsMargins(0,0,0,0); + setContentsMargins(0,0,0,0); + + maxLabel = new QLabel(tr("Max")); + varianceLabel = new QLabel(tr("Variance")); + + max = new QDoubleSpinBox(); + max->setMaximum(9999.99); + max->setMinimum(0); + max->setSingleStep(1); + + variance = new QDoubleSpinBox(); + variance->setMaximum(9999); + variance->setMinimum(0); + variance->setSingleStep(50); + + layout->addWidget(maxLabel); + layout->addWidget(max); + layout->addWidget(varianceLabel); + layout->addWidget(variance); + layout->addStretch(); + } + + //~FixSpikesConfig() {} // deliberately not declared since Qt will delete + // the widget and its children when the config pane is deleted + + QString explain() { + return(QString(tr("Occasionally power meters will erroneously " + "report high values for power. For crank based " + "power meters such as SRM and Quarq this is " + "caused by an erroneous cadence reading " + "as a result of triggering a reed switch " + "whilst pushing off\n\n" + "This function will look for spikes/anomalies " + "in power data and replace the erroneous data " + "by smoothing/interpolating the data from either " + "side of the point in question\n\n" + "It takes the following parameters:\n\n" + "Absolute Max - this defines an absolute value " + "for watts, and will smooth any values above this " + "absolute value that have been identified as being " + "anomalies (i.e. at odds with the data surrounding it)\n\n" + "Variance (%) - this will smooth any values which " + "are higher than this percentage of the rolling " + "average wattage for the 30 seconds leading up " + "to the spike."))); + } + + void readConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + double tol = settings->value(GC_DPFS_MAX, "1500").toDouble(); + double stop = settings->value(GC_DPFS_VARIANCE, "1000").toDouble(); + max->setValue(tol); + variance->setValue(stop); + } + + void saveConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + settings->setValue(GC_DPFS_MAX, max->value()); + settings->setValue(GC_DPFS_VARIANCE, variance->value()); + } +}; + + +// RideFile Dataprocessor -- used to handle gaps in recording +// by inserting interpolated/zero samples +// to ensure dataPoints are contiguous in time +// +class FixSpikes : public DataProcessor { + + public: + FixSpikes() {} + ~FixSpikes() {} + + // the processor + bool postProcess(RideFile *, DataProcessorConfig* config); + + // the config widget + DataProcessorConfig* processorConfig(QWidget *parent) { + return new FixSpikesConfig(parent); + } +}; + +static bool fixSpikesAdded = DataProcessorFactory::instance().registerProcessor(QString(tr("Fix Power Spikes")), new FixSpikes()); + +bool +FixSpikes::postProcess(RideFile *ride, DataProcessorConfig *config=0) +{ + // does this ride have power? + if (ride->areDataPresent()->watts == false) return false; + + // get settings + double variance, max; + if (config == NULL) { // being called automatically + boost::shared_ptr settings = GetApplicationSettings(); + max = settings->value(GC_DPFS_MAX, "1500").toDouble(); + variance = settings->value(GC_DPFS_VARIANCE, "1000").toDouble(); + } else { // being called manually + max = ((FixSpikesConfig*)(config))->max->value(); + variance = ((FixSpikesConfig*)(config))->variance->value(); + } + + int windowsize = 30 / ride->recIntSecs(); + + // We use a window size of 30s to find spikes + // if the ride is shorter, don't bother + // is no way of post processing anyway (e.g. manual workouts) + if (windowsize > ride->dataPoints().count()) return false; + + // Find the power outliers + + int spikes = 0; + double spiketime = 0.0; + + // create a data array for the outlier algorithm + QVector power; + QVector secs; + + foreach (RideFilePoint *point, ride->dataPoints()) { + power.append(point->watts); + secs.append(point->secs); + } + + LTMOutliers *outliers = new LTMOutliers(secs.data(), power.data(), power.count(), windowsize, false); + ride->command->startLUW("Fix Spikes in Recording"); + for (int i=0; igetDeviationForRank(i) < variance) break; + + // ok, so its highly variant but is it over + // the max value we are willing to accept? + if (outliers->getYForRank(i) < max) continue; + + // Houston, we have a spike + spikes++; + spiketime += ride->recIntSecs(); + + // which one is it + int pos = outliers->getIndexForRank(i); + double left=0.0, right=0.0; + + if (pos > 0) left = ride->dataPoints()[pos-1]->watts; + if (pos < (ride->dataPoints().count()-1)) right = ride->dataPoints()[pos+1]->watts; + + ride->command->setPointValue(pos, RideFile::watts, (left+right)/2.0); + } + ride->command->endLUW(); + + ride->setTag("Spikes", QString("%1").arg(spikes)); + ride->setTag("Spike Time", QString("%1").arg(spiketime)); + + if (spikes) return true; + else return false; +} diff --git a/src/FixTorque.cpp b/src/FixTorque.cpp new file mode 100644 index 000000000..bb8433835 --- /dev/null +++ b/src/FixTorque.cpp @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2010 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 "DataProcessor.h" +#include "LTMOutliers.h" +#include "Settings.h" +#include "Units.h" +#include +#include + +#define tr(s) QObject::tr(s) + +// Config widget used by the Preferences/Options config panes +class FixTorque; +class FixTorqueConfig : public DataProcessorConfig +{ + friend class ::FixTorque; + protected: + QHBoxLayout *layout; + QLabel *taLabel; + QLineEdit *ta; + + public: + FixTorqueConfig(QWidget *parent) : DataProcessorConfig(parent) { + + layout = new QHBoxLayout(this); + + layout->setContentsMargins(0,0,0,0); + setContentsMargins(0,0,0,0); + + taLabel = new QLabel(tr("Torque Adjust")); + + ta = new QLineEdit(); + + layout->addWidget(taLabel); + layout->addWidget(ta); + layout->addStretch(); + } + + //~FixTorqueConfig() {} // deliberately not declared since Qt will delete + // the widget and its children when the config pane is deleted + + QString explain() { + return(QString(tr("Adjusting torque values allows you to " + "uplift or degrade the torque values when the calibration " + "of your power meter was incorrect. It " + "takes a single parameter:\n\n" + "Torque Adjust - this defines an absolute value " + "in poinds per square inch or newton meters to " + "modify values by. Negative values are supported. (e.g. enter \"1.2 nm\" or " + "\"-0.5 pi\")."))); + } + + void readConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + ta->setText(settings->value(GC_DPTA, "0 nm").toString()); + } + + void saveConfig() { + boost::shared_ptr settings = GetApplicationSettings(); + settings->setValue(GC_DPTA, ta->text()); + } +}; + + +// RideFile Dataprocessor -- used to handle gaps in recording +// by inserting interpolated/zero samples +// to ensure dataPoints are contiguous in time +// +class FixTorque : public DataProcessor { + + public: + FixTorque() {} + ~FixTorque() {} + + // the processor + bool postProcess(RideFile *, DataProcessorConfig* config); + + // the config widget + DataProcessorConfig* processorConfig(QWidget *parent) { + return new FixTorqueConfig(parent); + } +}; + +static bool fixTorqueAdded = DataProcessorFactory::instance().registerProcessor(QString(tr("Adjust Torque Values")), new FixTorque()); + +bool +FixTorque::postProcess(RideFile *ride, DataProcessorConfig *config=0) +{ + // does this ride have torque? + if (ride->areDataPresent()->nm == false) return false; + + // Lets do it then! + QString ta; + double nmAdjust; + + if (config == NULL) { // being called automatically + boost::shared_ptr settings = GetApplicationSettings(); + ta = settings->value(GC_DPTA, "0 nm").toString(); + } else { // being called manually + ta = ((FixTorqueConfig*)(config))->ta->text(); + } + + // patrick's torque adjustment code + bool pi = ta.endsWith("pi", Qt::CaseInsensitive); + if (pi || ta.endsWith("nm", Qt::CaseInsensitive)) { + nmAdjust = ta.left(ta.length() - 2).toDouble(); + if (pi) { + nmAdjust *= 0.11298482933; + } + } else { + nmAdjust = ta.toDouble(); + } + + // no adjustment required + if (nmAdjust == 0) return false; + + // apply the change + ride->command->startLUW("Adjust Torque"); + for (int i=0; idataPoints().count(); i++) { + RideFilePoint *point = ride->dataPoints()[i]; + + if (point->nm != 0) { + double newnm = point->nm + nmAdjust; + ride->command->setPointValue(i, RideFile::watts, point->watts * (newnm / point->nm)); + ride->command->setPointValue(i, RideFile::nm, newnm); + } + } + ride->command->endLUW(); + + double currentta = ride->getTag("Torque Adjust", "0.0").toDouble(); + ride->setTag("Torque Adjust", QString("%1 nm").arg(currentta + nmAdjust)); + + return true; +} diff --git a/src/LTMOutliers.cpp b/src/LTMOutliers.cpp new file mode 100644 index 000000000..d5592c063 --- /dev/null +++ b/src/LTMOutliers.cpp @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010 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 +#include +#include +#include "LTMOutliers.h" + +#include + + +LTMOutliers::LTMOutliers(double *xdata, double *ydata, int count, int windowsize, bool absolute) : stdDeviation(0.0) +{ + double sum = 0; + int points = 0; + double allSum = 0.0; + int pos=0; + + assert(count >= windowsize); + + // initial samples from point 0 to windowsize + for (; pos < windowsize; ++pos) { + + // we could either use a deviation of zero + // or base it on what we have so far... + // I chose to use sofar since spikes + // are common at the start of a ride + xdev add; + add.x = xdata[pos]; + add.y = ydata[pos]; + add.pos = pos; + + if (absolute) add.deviation = fabs(ydata[pos] - (sum/windowsize)); + else add.deviation = ydata[pos] - (sum/windowsize); + + rank.append(add); + + // when using -ve and +ve values stdDeviation is + // based upon the absolute value of deviation + // when not, we should only look at +ve values + if ((!absolute && add.deviation > 0) || absolute) { + allSum += add.deviation; + points++; + } + sum += ydata[pos]; // initialise the moving average + } + + // bulk of samples from windowsize to the end + for (; pos 0) || absolute) { + allSum += add.deviation; + points++; + } + } + + // and to the list of deviations + // calculate the average deviation across all points + stdDeviation = allSum / (double)points; + + // create a ranked list + qSort(rank); +} diff --git a/src/LTMOutliers.h b/src/LTMOutliers.h new file mode 100644 index 000000000..735b008d5 --- /dev/null +++ b/src/LTMOutliers.h @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2010 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 + */ + +#ifndef _GC_LTMOutliers_h +#define _GC_LTMOutliers_h 1 + +#include +#include + +class LTMOutliers +{ + // used to produce a sorted list + struct xdev { + double x,y; + int pos; + double deviation; + + bool operator< (xdev right) const { + return (deviation > right.deviation); // sort ascending! (.gt not .lt) + } + }; + + public: + // Constructor using arrays of x values and y values + LTMOutliers(double *x, double *y, int count, int windowsize, bool absolute=true); + + // ranked values + int getIndexForRank(int i) { return rank[i].pos; } + double getXForRank(int i) { return rank[i].x; } + double getYForRank(int i) { return rank[i].y; } + double getDeviationForRank(int i) { return rank[i].deviation; } + + // std deviation + double getStdDeviation() { return stdDeviation; } + + protected: + double stdDeviation; + QVector rank; // ranked list of x sorted by deviation +}; + +#endif diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 522531a75..905ebabf4 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -38,6 +38,7 @@ #include "RealtimeWindow.h" #include "RideItem.h" #include "IntervalItem.h" +#include "RideEditor.h" #include "RideFile.h" #include "RideSummaryWindow.h" #include "RideImportWizard.h" @@ -70,9 +71,6 @@ #define GC_VERSION "(developer build)" #endif -#define FOLDER_TYPE 0 -#define RIDE_TYPE 1 - bool MainWindow::parseRideFileName(const QString &name, QString *notesFileName, QDateTime *dt) { @@ -322,6 +320,11 @@ MainWindow::MainWindow(const QDir &home) : googleMap = new GoogleMapControl(this); tabs.append(TabInfo(googleMap, tr("Map"))); + ///////////////////////////// Editor ////////////////////////////////// + + rideEdit = new RideEditor(this); + tabs.append(TabInfo(rideEdit, tr("Editor"))); + ////////////////////////////// Signals ////////////////////////////// connect(calendar, SIGNAL(clicked(const QDate &)), @@ -392,6 +395,29 @@ MainWindow::MainWindow(const QDir &home) : //optionsMenu->addAction(tr("&Update Metrics..."), this, // SLOT(scanForMissing()()), tr("Ctrl+U")); + // get the available processors + const DataProcessorFactory &factory = DataProcessorFactory::instance(); + QMap processors = factory.getProcessors(); + + if (processors.count()) { + + optionsMenu->addSeparator(); + toolMapper = new QSignalMapper(this); // maps each option + QMapIterator i(processors); + connect(toolMapper, SIGNAL(mapped(const QString &)), this, SLOT(manualProcess(const QString &))); + + i.toFront(); + while (i.hasNext()) { + i.next(); + QAction *action = new QAction(QString("%1...").arg(i.key()), this); + optionsMenu->addAction(action); + connect(action, SIGNAL(triggered()), toolMapper, SLOT(map())); + toolMapper->setMapping(action, i.key()); + } + } + + + QMenu *viewMenu = menuBar()->addMenu(tr("&View")); QStringList tabsToHide = settings->value(GC_TABS_TO_HIDE, "").toString().split(","); for (int i = 0; i < tabs.size(); ++i) { @@ -507,7 +533,7 @@ MainWindow::addRide(QString name, bool bSelect /*=true*/) QTreeWidgetItem *item = allRides->child(index); if (item->type() != RIDE_TYPE) continue; - RideItem *other = reinterpret_cast(item); + RideItem *other = static_cast(item); if(isAscending.toInt() > 0 ){ if (other->dateTime > dt) @@ -541,7 +567,7 @@ MainWindow::removeCurrentRide() QTreeWidgetItem *_item = treeWidget->currentItem(); if (_item->type() != RIDE_TYPE) return; - RideItem *item = reinterpret_cast(_item); + RideItem *item = static_cast(_item); rideDeleted(item); @@ -856,6 +882,9 @@ MainWindow::showTreeContextMenuPopup(const QPoint &pos) QAction *actSaveRide = new QAction(tr("Save Changes to Ride"), treeWidget); connect(actSaveRide, SIGNAL(triggered(void)), this, SLOT(saveRide())); + QAction *revertRide = new QAction(tr("Revert to Saved Ride"), treeWidget); + connect(revertRide, SIGNAL(triggered(void)), this, SLOT(revertRide())); + QAction *actDeleteRide = new QAction(tr("Delete Ride"), treeWidget); connect(actDeleteRide, SIGNAL(triggered(void)), this, SLOT(deleteRide())); @@ -870,8 +899,10 @@ MainWindow::showTreeContextMenuPopup(const QPoint &pos) - if (rideItem->isDirty() == true) + if (rideItem->isDirty() == true) { menu.addAction(actSaveRide); + menu.addAction(revertRide); + } menu.addAction(actDeleteRide); menu.addAction(actBestInt); @@ -1175,7 +1206,10 @@ void MainWindow::closeEvent(QCloseEvent* event) { if (saveRideExitDialog() == false) event->ignore(); - saveNotes(); + else { + saveNotes(); + QApplication::clipboard()->setText(""); + } } void @@ -1295,6 +1329,17 @@ MainWindow::saveRide() saveRideSingleDialog(ride); // will update Dirty flag if saved } +void +MainWindow::revertRide() +{ + ride->freeMemory(); + ride->ride(); // force re-load + + // in case reverted ride has different starttime + ride->setStartTime(ride->ride()->startTime()); // Note: this will also signal rideSelected() + ride->ride()->emitReverted(); +} + void MainWindow::splitRide() { @@ -1307,7 +1352,7 @@ MainWindow::deleteRide() QTreeWidgetItem *_item = treeWidget->currentItem(); if (_item==NULL || _item->type() != RIDE_TYPE) return; - RideItem *item = reinterpret_cast(_item); + RideItem *item = static_cast(_item); QMessageBox msgBox; msgBox.setText(tr("Are you sure you want to delete the ride:")); msgBox.setInformativeText(item->fileName); @@ -1364,3 +1409,19 @@ MainWindow::notifyRideSelected() { rideSelected(); } + +void +MainWindow::manualProcess(QString name) +{ + // open a dialog box and let the users + // configure the options to use + // and also show the explanation + // of what this function does + // then call it! + RideItem *rideitem = (RideItem*)currentRideItem(); + if (rideitem) { + ManualDataProcessorDialog *p = new ManualDataProcessorDialog(this, name, rideitem); + p->setWindowModality(Qt::ApplicationModal); // don't allow select other ride or it all goes wrong! + p->exec(); + } +} diff --git a/src/MainWindow.h b/src/MainWindow.h index ee7470082..06bc9b079 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -51,6 +51,7 @@ class PerformanceManagerWindow; class RideSummaryWindow; class ViewSelection; class TrainWindow; +class RideEditor; class MainWindow : public QMainWindow { @@ -84,6 +85,8 @@ class MainWindow : public QMainWindow // signal emitted to notify its children void notifyRideSelected(); // used by RideItem to notify when // rideItem date/time changes + void notifyRideClean() { rideClean(); } + void notifyRideDirty() { rideDirty(); } void selectView(int); // db connections to cyclistdir/metricDB - one per active MainWindow @@ -111,6 +114,8 @@ class MainWindow : public QMainWindow void viewChanged(int); void rideAdded(RideItem *); void rideDeleted(RideItem *); + void rideDirty(); + void rideClean(); private slots: void tabViewTriggered(bool); @@ -125,6 +130,7 @@ class MainWindow : public QMainWindow void manualRide(); void exportCSV(); void exportGC(); + void manualProcess(QString); void importFile(); void findBestIntervals(); void addIntervalForPowerPeaksForSecs(RideFile *ride, int windowSizeSecs, QString name); @@ -135,6 +141,7 @@ class MainWindow : public QMainWindow void aboutDialog(); void notesChanged(); void saveRide(); // save current ride menu item + void revertRide(); bool saveRideExitDialog(); // save dirty rides on exit dialog void saveNotes(); void showOptions(); @@ -192,6 +199,7 @@ class MainWindow : public QMainWindow ModelWindow *modelWindow; AerolabWindow *aerolabWindow; GoogleMapControl *googleMap; + RideEditor *rideEdit; QTreeWidgetItem *allRides; QTreeWidgetItem *allIntervals; QSplitter *leftLayout; @@ -218,6 +226,8 @@ class MainWindow : public QMainWindow bool useMetricUnits; // whether metric units are used (or imperial) QuarqdClient *client; + + QSignalMapper *toolMapper; }; #endif // _GC_MainWindow_h diff --git a/src/Pages.cpp b/src/Pages.cpp index 424899cc6..51b33b8eb 100644 --- a/src/Pages.cpp +++ b/src/Pages.cpp @@ -843,10 +843,12 @@ MetadataPage::MetadataPage(MainWindow *main) : main(main) // setup maintenance pages using current config fieldsPage = new FieldsPage(this, fieldDefinitions); keywordsPage = new KeywordsPage(this, keywordDefinitions); + processorPage = new ProcessorPage(main); tabs = new QTabWidget(this); tabs->addTab(fieldsPage, tr("Fields")); tabs->addTab(keywordsPage, tr("Notes Keywords")); + tabs->addTab(processorPage, tr("Processing")); layout->addWidget(tabs); } @@ -860,6 +862,9 @@ MetadataPage::saveClicked() // write to metadata.xml RideMetadata::serialize(main->home.absolutePath() + "/metadata.xml", keywordDefinitions, fieldDefinitions); + + // save processors config + processorPage->saveClicked(); } // little helper since we create/recreate combos @@ -1219,6 +1224,81 @@ FieldsPage::getDefinitions(QList &fieldList) } } +// +// Data processors config page +// +ProcessorPage::ProcessorPage(MainWindow *main) : main(main) +{ + // get the available processors + const DataProcessorFactory &factory = DataProcessorFactory::instance(); + processors = factory.getProcessors(); + + QGridLayout *mainLayout = new QGridLayout(this); + + processorTree = new QTreeWidget; + processorTree->headerItem()->setText(0, tr("Processor")); + processorTree->headerItem()->setText(1, tr("Apply")); + processorTree->headerItem()->setText(2, tr("Settings")); + processorTree->setColumnCount(3); + processorTree->setSelectionMode(QAbstractItemView::NoSelection); + processorTree->setEditTriggers(QAbstractItemView::SelectedClicked); // allow edit + processorTree->setUniformRowHeights(true); + processorTree->setIndentation(0); + processorTree->header()->resizeSection(0,150); + + // iterate over all the processors and add an entry to the + QMapIterator i(processors); + i.toFront(); + while (i.hasNext()) { + i.next(); + + QTreeWidgetItem *add; + + add = new QTreeWidgetItem(processorTree->invisibleRootItem()); + add->setFlags(add->flags() & ~Qt::ItemIsEditable); + + // Processor Name + add->setText(0, i.key()); + + // Auto or Manual run? + QComboBox *comboButton = new QComboBox(this); + comboButton->addItem(tr("Manual")); + comboButton->addItem(tr("Auto")); + processorTree->setItemWidget(add, 1, comboButton); + + QString configsetting = QString("dp/%1/apply").arg(i.key()); + boost::shared_ptr settings = GetApplicationSettings(); + if (settings->value(configsetting, "Manual").toString() == "Manual") + comboButton->setCurrentIndex(0); + else + comboButton->setCurrentIndex(1); + + // Get and Set the Config Widget + DataProcessorConfig *config = i.value()->processorConfig(this); + config->readConfig(); + + processorTree->setItemWidget(add, 2, config); + } + + mainLayout->addWidget(processorTree, 0,0); +} + +void +ProcessorPage::saveClicked() +{ + // call each processor config widget's saveConfig() to + // write away separately + boost::shared_ptr settings = GetApplicationSettings(); + for (int i=0; iinvisibleRootItem()->childCount(); i++) { + // auto or manual? + QString configsetting = QString("dp/%1/apply").arg(processorTree->invisibleRootItem()->child(i)->text(0)); + QString apply = ((QComboBox*)(processorTree->itemWidget(processorTree->invisibleRootItem()->child(i), 1)))->currentIndex() ? + "Auto" : "Manual"; + settings->setValue(configsetting, apply); + ((DataProcessorConfig*)(processorTree->itemWidget(processorTree->invisibleRootItem()->child(i), 2)))->saveConfig(); + } +} + // // Zone Config page // diff --git a/src/Pages.h b/src/Pages.h index b8e45e0fe..2c9909c4c 100644 --- a/src/Pages.h +++ b/src/Pages.h @@ -21,6 +21,7 @@ #include "DeviceTypes.h" #include "DeviceConfiguration.h" #include "RideMetadata.h" +#include "DataProcessor.h" class QGroupBox; class QHBoxLayout; @@ -265,6 +266,30 @@ class FieldsPage : public QWidget QPushButton *upButton, *downButton, *addButton, *renameButton, *deleteButton; }; +class ProcessorPage : public QWidget +{ + Q_OBJECT + + public: + + ProcessorPage(MainWindow *main); + void saveClicked(); + + public slots: + + //void upClicked(); + //void downClicked(); + + protected: + + MainWindow *main; + QMap processors; + + QTreeWidget *processorTree; + //QPushButton *upButton, *downButton; + +}; + class MetadataPage : public QWidget { Q_OBJECT @@ -285,6 +310,7 @@ class MetadataPage : public QWidget QTabWidget *tabs; KeywordsPage *keywordsPage; FieldsPage *fieldsPage; + ProcessorPage *processorPage; // local versions for modification QList keywordDefinitions; diff --git a/src/RideEditor.cpp b/src/RideEditor.cpp new file mode 100644 index 000000000..85167f709 --- /dev/null +++ b/src/RideEditor.cpp @@ -0,0 +1,2241 @@ +/* + * Copyright (c) 2010 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 "RideEditor.h" +#include "LTMOutliers.h" +#include "MainWindow.h" +#include "Settings.h" + +#include +#include + +// used to make a lookup string for row/col anomalies +static QString xsstring(int x, RideFile::SeriesType series) +{ + return QString("%1:%2").arg((int)x).arg(static_cast(series)); +} +static void unxsstring(QString val, int &x, RideFile::SeriesType &series) +{ + QRegExp it("^([^:]*):([^:]*)$"); + it.exactMatch(val); + x = it.cap(1).toDouble(); + series = static_cast(it.cap(2).toInt()); +} + +static void secsMsecs(double value, int &secs, int &msecs) +{ + // split into secs and msecs from a double + // tried modf, floor, round and a host of others but + // they all had difference problems. In the end + // I've resorted to rounding to 100ths of a second. + // I acknowledge that this is horrid, but its ok + // for Powertaps but maybe more precise devices will + // come along? + secs = floor(value); // assume it is positive!! .. it is a time field! + msecs = round((value - secs) * 100) * 10; +} + +RideEditor::RideEditor(MainWindow *main) : QWidget(main), data(NULL), ride(NULL), main(main), inLUW(false) +{ + QVBoxLayout *mainLayout = new QVBoxLayout(this); + + //Left in the code to display a title, but + //its a waste of screen estate, maybe uncomment + //it if someone finds it useful + + //title = new QLabel(tr("No ride selected")); + //QFont font; + //font.setWeight(Qt::black); + //title->setFont(font); + //title->setAlignment(Qt::AlignHCenter); + + // setup the toolbar + toolbar = new QToolBar(this); + toolbar->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + toolbar->setFloatable(true); + + QIcon saveIcon(":images/toolbar/save.png"); + saveAct = new QAction(saveIcon, tr("Save"), this); + connect(saveAct, SIGNAL(triggered()), this, SLOT(saveFile())); + toolbar->addAction(saveAct); + + QIcon findIcon(":images/toolbar/search.png"); + searchAct = new QAction(findIcon, tr("Find"), this); + connect(searchAct, SIGNAL(triggered()), this, SLOT(find())); + toolbar->addAction(searchAct); + + // ***************************************************** + // REMOVED MANUALLY RUNNING A CHECK SINCE IT IS NOW + // PRETTY EFFICIENT AND UPDATES AUTOMATICALLY WHEN + // A COMMAND COMPLETES. IF THIS BECOMES TOO MUCH OF A + // PERFORMANCE OVERHEAD THEN WE CAN RE-ADD THIS + // ***************************************************** + //QIcon checkIcon(":images/toolbar/splash green.png"); + //checkAct = new QAction(checkIcon, tr("Check"), this); + //connect(checkAct, SIGNAL(triggered()), this, SLOT(check())); + //toolbar->addAction(checkAct); + + // undo and redo deliberately at a distance from the + // save icon, since accidentally hitting the wrong + // icon in that instance would be horrible + QIcon undoIcon(":images/toolbar/undo.png"); + undoAct = new QAction(undoIcon, tr("Undo"), this); + connect(undoAct, SIGNAL(triggered()), this, SLOT(undo())); + toolbar->addAction(undoAct); + + QIcon redoIcon(":images/toolbar/redo.png"); + redoAct = new QAction(redoIcon, tr("Redo"), this); + connect(redoAct, SIGNAL(triggered()), this, SLOT(redo())); + toolbar->addAction(redoAct); + + // empty model + model = new RideFileTableModel(NULL); + + // set up the table + table = new QTableView(); + table->setItemDelegate(new CellDelegate(this)); + table->verticalHeader()->setDefaultSectionSize(20); + table->setModel(model); + table->setContextMenuPolicy(Qt::CustomContextMenu); + table->setSelectionMode(QAbstractItemView::ContiguousSelection); + table->installEventFilter(this); + + // prettify (and make anomalies more visible) + QPen gridStyle; + gridStyle.setColor(Qt::lightGray); + table->setGridStyle(Qt::DotLine); + + connect(table, SIGNAL(customContextMenuRequested(const QPoint&)), this, SLOT(cellMenu(const QPoint&))); + + // layout the widget + //mainLayout->addWidget(title); + mainLayout->addWidget(toolbar); + mainLayout->addWidget(table); + + // get latest config + configChanged(); + + // trap GC signals + connect(main, SIGNAL(configChanged()), this, SLOT(configChanged())); + connect(main, SIGNAL(intervalSelected()), this, SLOT(intervalSelected())); + connect(main, SIGNAL(rideSelected()), this, SLOT(rideSelected())); + connect(main, SIGNAL(rideDirty()), this, SLOT(rideDirty())); + connect(main, SIGNAL(rideClean()), this, SLOT(rideClean())); +} + +void +RideEditor::configChanged() +{ + boost::shared_ptr settings = GetApplicationSettings(); + + // get spike config + DPFSmax = settings->value(GC_DPFS_MAX, "1500").toDouble(); + DPFSvariance = settings->value(GC_DPFS_VARIANCE, "1000").toDouble(); +} + + +//---------------------------------------------------------------------- +// RideEditor table/model/rideFile utility functions +//---------------------------------------------------------------------- + + +// what are the available columns? (used by insert column context menu) +QList +RideEditor::whatColumns() +{ + QList what; + + what << tr("Time") + << tr("Distance") + << tr("Power") + << tr("Heartrate") + << tr("Cadence") + << tr("Speed") + << tr("Torque") + << tr("Altitude") + << tr("Latitude") + << tr("Longitude") + << tr("Headwind") + << tr("Interval"); + + return what; +} + +double +RideEditor::getValue(int row, int column) +{ + if (row < 0 || column < 0) return 0.0; + + return ride->ride()->getPointValue(row, model->columnType(column)); +} + +void +RideEditor::setModelValue(int row, int col, double value) +{ + model->setValue(row, col, value); +} + +bool +RideEditor::isAnomaly(int row, int col) +{ + if (row < 0 || col < 0) return false; + + if (data->anomalies.value(xsstring(row,model->columnType(col)), "") != "") + return true; + + return false; +} + +bool +RideEditor::isFound(int row, int col) +{ + if (row < 0 || col < 0) return false; + + if (data->found.value(xsstring(row,model->columnType(col)), "") != "") + return true; + + return false; +} + +bool +RideEditor::isTooPrecise(int row, int column) +{ + if (row < 0 || column < 0) return false; + + RideFile::SeriesType what = model->columnType(column); + int dp; + QString value = QString("%1").arg(getValue(row, column), 0, 'g', 10); + + if ((dp = value.indexOf(".")) >= 0) + if (value.length()-(dp+1) > RideFile::decimalsFor(what)) return true; + + return false; +} + +bool +RideEditor::isRowSelected() +{ + QList selection = table->selectionModel()->selection().indexes(); + + if (selection.count() > 0 && + selection[0].column() == 0 && + selection[selection.count()-1].column() == (model->columnCount()-1)) + return true; + + return false; +} + +bool +RideEditor::isColumnSelected() +{ + QList selection = table->selectionModel()->selection().indexes(); + + if (selection.count() > 0 && + selection[0].row() == 0 && + selection[selection.count()-1].row() == (ride->ride()->dataPoints().count()-1)) + return true; + + return false; +} + + +//---------------------------------------------------------------------- +// Toolbar functions +//---------------------------------------------------------------------- +void +RideEditor::saveFile() +{ + if (ride->isDirty()) { + main->saveRideSingleDialog(ride); + } +} + +void +RideEditor::undo() +{ + ride->ride()->command->undoCommand(); +} + +void +RideEditor::redo() +{ + ride->ride()->command->redoCommand(); +} + +void +RideEditor::find() +{ + // look for a value in a range and allow user to next/previous across + //RideEditorFindDialog finder(this, table); + //finder.exec(); + FindDialog *finder = new FindDialog(this); + + // close when a new ride is selected + connect(main, SIGNAL(rideSelected()), finder, SLOT(close())); + finder->show(); +} + +void +RideEditor::check() +{ + // run through all the available channels and find anomalies + data->anomalies.clear(); + + QVector power; + QVector secs; + double lastdistance; + int count = 0; + + foreach (RideFilePoint *point, ride->ride()->dataPoints()) { + power.append(point->watts); + secs.append(point->secs); + + if (count) { + + // whilst we are here we might as well check for gaps in recording + // anything bigger than a second is of a material concern + // and we assume time always flows forward ;-) + double diff = secs[count] - (secs[count-1] + ride->ride()->recIntSecs()); + if (diff > (double)1.0 || diff < (double)-1.0 || secs[count] < secs[count-1]) { + data->anomalies.insert(xsstring(count, RideFile::secs), + tr("Invalid recording gap")); + } + + // and on the same theme what about distance going backwards? + if (point->km < lastdistance) + data->anomalies.insert(xsstring(count, RideFile::km), + tr("Distance goes backwards.")); + + } + lastdistance = point->km; + + // suspicious values + if (point->cad > 150) { + data->anomalies.insert(xsstring(count, RideFile::cad), + tr("Suspiciously high cadence")); + } + if (point->hr > 200) { + data->anomalies.insert(xsstring(count, RideFile::hr), + tr("Suspiciously high heartrate")); + } + if (point->kph > 100) { + data->anomalies.insert(xsstring(count, RideFile::kph), + tr("Suspiciously high speed")); + } + if (point->lat > 90 || point->lat < -90) { + data->anomalies.insert(xsstring(count, RideFile::lat), + tr("Out of bounds value")); + } + if (point->lon > 180 || point->lon < -180) { + data->anomalies.insert(xsstring(count, RideFile::lon), + tr("Out of bounds value")); + } + if (ride->ride()->areDataPresent()->cad && point->nm && !point->cad) { + data->anomalies.insert(xsstring(count, RideFile::nm), + tr("Non-zero torque but zero cadence")); + + } + count++; + } + + // lets look at the Power Column if its there and has enough data + int column = model->headings().indexOf(tr("Power")); + if (column >= 0 && ride->ride()->dataPoints().count() >= 30) { + + + LTMOutliers *outliers = new LTMOutliers(secs.data(), power.data(), power.count(), 30, false); + + // run through the ranked list + for (int i=0; igetDeviationForRank(i) < DPFSvariance) break; + + // ok, so its highly variant but is it over + // the max value we are willing to accept? + if (outliers->getYForRank(i) < DPFSmax) continue; + + // which one is it + data->anomalies.insert(xsstring(outliers->getIndexForRank(i), RideFile::watts), tr("Data spike candidate")); + } + } + + // redraw - even if no anomalies were found since + // some may have been highlighted previouslt. This is + // an expensive operation, but then so is the check() + // function. + model->forceRedraw(); +} + +//---------------------------------------------------------------------- +// handle TableView signals / events +//---------------------------------------------------------------------- +bool +RideEditor::eventFilter(QObject *object, QEvent *e) +{ + // not for the table? + if (object != (QObject *)table) return false; + + // what happenned? + switch(e->type()) + { + case QEvent::ContextMenu: + borderMenu(((QMouseEvent *)e)->pos()); + return true; // I'll take that thanks + break; + + case QEvent::KeyPress: + { + QKeyEvent *keyEvent = static_cast(e); + if (keyEvent->modifiers() & Qt::ControlModifier) { + switch (keyEvent->key()) { + + case Qt::Key_C: // defacto standard for copy + copy(); + return true; + + case Qt::Key_V: // defacto standard for paste + paste(); + return true; + + case Qt::Key_X: // defacto standard for cut + cut(); + return true; + + case Qt::Key_Y: // emerging standard for redo + redo(); + return true; + + case Qt::Key_Z: // common standard for undo + undo(); + return true; + + case Qt::Key_0: + clear(); + return true; + + default: + return false; + } + } + break; + } + + default: + break; + } + return false; +} + +void +RideEditor::stdContextMenu(QMenu *menu, const QPoint &pos) +{ + int row = table->indexAt(pos).row(); + int column = table->indexAt(pos).column(); + + QIcon undoIcon(":images/toolbar/undo.png"); + QIcon redoIcon(":images/toolbar/redo.png"); + QIcon cutIcon(":images/toolbar/cut.png"); + QIcon pasteIcon(":images/toolbar/paste.png"); + QIcon copyIcon(":images/toolbar/copy.png"); + + bool pastable = QApplication::clipboard()->text() == "" ? false : true; + + // setup all the actions + QAction *undoAct = new QAction(undoIcon, tr("Undo"), table); + undoAct->setShortcut(QKeySequence("Ctrl+Z")); + undoAct->setEnabled(ride->ride()->command->undoCount() > 0); + menu->addAction(undoAct); + connect(undoAct, SIGNAL(triggered()), this, SLOT(undo())); + + QAction *redoAct = new QAction(redoIcon, tr("Redo"), table); + redoAct->setShortcut(QKeySequence("Ctrl+Y")); + redoAct->setEnabled(ride->ride()->command->redoCount() > 0); + menu->addAction(redoAct); + connect(redoAct, SIGNAL(triggered()), this, SLOT(redo())); + + menu->addSeparator(); + + QAction *cutAct = new QAction(cutIcon, tr("Cut"), table); + cutAct->setShortcut(QKeySequence("Ctrl+X")); + cutAct->setEnabled(isRowSelected() || isColumnSelected()); + menu->addAction(cutAct); + connect(cutAct, SIGNAL(triggered()), this, SLOT(cut())); + + QAction *copyAct = new QAction(copyIcon, tr("Copy"), table); + copyAct->setShortcut(QKeySequence("Ctrl+C")); + copyAct->setEnabled(true); + menu->addAction(copyAct); + connect(copyAct, SIGNAL(triggered()), this, SLOT(copy())); + + QAction *pasteAct = new QAction(pasteIcon, tr("Paste"), table); + pasteAct->setShortcut(QKeySequence("Ctrl+V")); + pasteAct->setEnabled(pastable); + menu->addAction(pasteAct); + connect(pasteAct, SIGNAL(triggered()), this, SLOT(paste())); + + QAction *specialAct = new QAction(tr("Paste Special..."), table); + specialAct->setEnabled(pastable); + menu->addAction(specialAct); + connect(specialAct, SIGNAL(triggered()), this, SLOT(pasteSpecial())); + + QAction *clearAct = new QAction(tr("Clear Contents"), table); + clearAct->setShortcut(QKeySequence("Ctrl+0")); + clearAct->setEnabled(true); + menu->addAction(clearAct); + connect(clearAct, SIGNAL(triggered()), this, SLOT(clear())); + + currentCell.row = row; + currentCell.column = column; +} + +void +RideEditor::cellMenu(const QPoint &pos) +{ + + int row = table->indexAt(pos).row(); + int column = table->indexAt(pos).column(); + bool anomaly = isAnomaly(row, column); + + QMenu menu(table); + stdContextMenu(&menu, pos); + + menu.addSeparator(); + + QAction *smoothAnomaly = new QAction(tr("Smooth Anomaly"), table); + smoothAnomaly->setEnabled(anomaly); + menu.addAction(smoothAnomaly); + connect(smoothAnomaly, SIGNAL(triggered(void)), this, SLOT(smooth())); + + currentCell.row = row < 0 ? 0 : row; + currentCell.column = column < 0 ? 0 : column; + menu.exec(table->mapToGlobal(QPoint(pos.x(), pos.y()+20))); +} + + +void +RideEditor::borderMenu(const QPoint &pos) +{ + + int column, row; + + // but we need to set the row or column to zero since + // we are in the border, this seems an easy and quick way + // to do this (the indexAt function assumes pos starts from + // 0 for row/col 0 and does not include the vertical + // or horizontal header width (which is what we go passed) + if (pos.y() <= table->horizontalHeader()->height()) { + row = 0; + QPoint tickle(pos.x() - table->verticalHeader()->width(), + pos.y()); + column = table->indexAt(tickle).column(); + } + if (pos.x() <= table->verticalHeader()->width()) { + column = 0; + QPoint tickle(pos.x(), pos.y() - table->horizontalHeader()->height()); + row = table->indexAt(tickle).row(); + } + + // avoid crash when pos translation failed + // just return with no menu options added + if (row < 0 && column < 0) return; + + QMenu menu(table); + stdContextMenu(&menu, pos); + + menu.addSeparator(); + + if (column <= 0) { + + QAction *delAct = new QAction(tr("Delete Row"), table); + delAct->setEnabled(isRowSelected()); + menu.addAction(delAct); + connect(delAct, SIGNAL(triggered()), this, SLOT(delRow())); + + QAction *insAct = new QAction(tr("Insert Row"), table); + insAct->setEnabled(true); + menu.addAction(insAct); + connect(insAct, SIGNAL(triggered()), this, SLOT(insRow())); + + } else if (row <= 0){ + + QAction *delAct = new QAction(tr("Remove Column"), table); + delAct->setEnabled(isColumnSelected()); + menu.addAction(delAct); + connect(delAct, SIGNAL(triggered()), this, SLOT(delColumn())); + + QMenu *insCol = new QMenu(tr("Add Column"), table); + insCol->setEnabled(true); + + // add menu options for each column + if (colMapper) delete colMapper; + colMapper = new QSignalMapper(this); + connect(colMapper, SIGNAL(mapped(const QString &)), this, SLOT(insColumn(const QString &))); + + foreach(QString heading, whatColumns()) { + QAction *insColAct = new QAction(heading, table); + connect(insColAct, SIGNAL(triggered()), colMapper, SLOT(map())); + insColAct->setEnabled(!model->headings().contains(heading)); + insCol->addAction(insColAct); + + // map action to column heading + colMapper->setMapping(insColAct, heading); + } + menu.addMenu(insCol); + } + + currentCell.row = row < 0 ? 0 : row; + currentCell.column = column < 0 ? 0 : column; + menu.exec(table->mapToGlobal(QPoint(pos.x(), pos.y()))); +} + +//---------------------------------------------------------------------- +// Context menu actions +//---------------------------------------------------------------------- +void +RideEditor::copy() +{ + QString copy; + QList selection = table->selectionModel()->selection().indexes(); + + if (selection.count() > 0) { + QString text; + for (int row = selection[0].row(); row <= selection[selection.count()-1].row(); row++) { + + for (int column = selection[0].column(); column <= selection[selection.count()-1].column(); column++) { + if (column == selection[selection.count()-1].column()) + text += QString("%1").arg(getValue(row,column)); + else + text += QString("%1\t").arg(getValue(row,column)); + } + text += "\n"; + } + QApplication::clipboard()->setText(text); + + // remember the headings we copied, so we can + // default them if we paste special them back + copyHeadings.clear(); + for (int column = selection[0].column(); column <= selection[selection.count()-1].column(); column++) + copyHeadings << model->headings()[column]; + } +} + +void +RideEditor::cut() +{ + copy(); + if (isRowSelected()) delRow(); + else if (isColumnSelected()) delColumn(); +} + +void +RideEditor::delRow() +{ + // run through the selected rows and zap them + bool changed = false; + QList selection = table->selectionModel()->selection().indexes(); + + if (selection.count() > 0) { + + changed = true; + + // delete from table - we do in one hit since row-by-row is VERY slow + ride->ride()->command->startLUW("Delete Rows"); + model->removeRows(selection[0].row(), + selection[selection.count()-1].row() - selection[0].row() + 1, QModelIndex()); + ride->ride()->command->endLUW(); + + } +} + +void +RideEditor::delColumn() +{ + // run through the selected columns and "zap" them + bool changed = false; + QList selection = table->selectionModel()->selection().indexes(); + + if (selection.count() > 0) { + + changed = true; + + // Delete each column by its SeriesType + ride->ride()->command->startLUW("Delete Columns"); + for(int column = selection.first().column(), + count = selection.last().column() - selection.first().column() + 1; + count > 0 ; count--) + model->removeColumn(model->columnType(column)); + ride->ride()->command->endLUW(); + } +} + +void +RideEditor::insRow() +{ + // add to model + model->insertRow(currentCell.row, QModelIndex()); +} + +void +RideEditor::insColumn(QString name) +{ + // update state data + RideFile::SeriesType series; + + if (name == tr("Time")) series = RideFile::secs; + if (name == tr("Distance")) series = RideFile::km; + if (name == tr("Power")) series = RideFile::watts; + if (name == tr("Cadence")) series = RideFile::cad; + if (name == tr("Speed")) series = RideFile::kph; + if (name == tr("Torque")) series = RideFile::nm; + if (name == tr("Latitude")) series = RideFile::lat; + if (name == tr("Longitude")) series = RideFile::lon; + if (name == tr("Altitude")) series = RideFile::alt; + if (name == tr("Headwind")) series = RideFile::headwind; + if (name == tr("Interval")) series = RideFile::interval; + if (name == tr("Heartrate")) series = RideFile::hr; + + model->insertColumn(series); +} + +void +RideEditor::paste() +{ + QVector > cells; + QStringList seps, head; + seps << "\t"; + + getPaste(cells, seps, head, false); + + // empty paste buffer + if (cells.count() == 0 || cells[0].count() == 0) return; + + // if selected range is not the same + // size as the copy buffer then barf + // unless just a single cell selected + QList selection = table->selectionModel()->selection().indexes(); + + // is anything selected? + if (selection.count() == 0) { + // wrong size + QMessageBox oops(QMessageBox::Critical, tr("Paste error"), + tr("Please select target cell or cells to paste values into.")); + oops.exec(); + return; + } + + int selectedrow = selection[0].row(); + int selectedcol = selection[0].column(); + int selectedrows = selection[selection.count()-1].row() - selectedrow + 1; + int selectedcols = selection[selection.count()-1].column() - selectedcol + 1; + + if (selection.count() > 1 && + (selectedrows != cells.count() || selectedcols != cells[0].count())) { + + // wrong size + QMessageBox oops(QMessageBox::Critical, tr("Paste error"), + tr("Copy buffer and selected area are diffferent sizes.")); + oops.exec(); + return; + } + + // overrun cols? + if (selection.count() == 1 && + (selectedcol + cells[0].count()) > model->columnCount()) { + QMessageBox oops(QMessageBox::Critical, tr("Paste error"), + tr("Copy buffer has more columns than available.")); + oops.exec(); + return; + } + + // overrun rows? + if (selection.count() == 1 && + (selectedrow + cells.count()) > ride->ride()->dataPoints().count()) { + QMessageBox oops(QMessageBox::Critical, tr("Paste error"), + tr("Copy buffer has more rows than available.")); + oops.exec(); + return; + } + + // go paste! + ride->ride()->command->startLUW("Paste Cells"); + for (int i=0; i ride->ride()->dataPoints().count()-1) break; + for(int j=0; j model->columnCount()-1)) break; + + // set table + setModelValue(selectedrow+i, selectedcol+j, cells[i][j]); + } + } + ride->ride()->command->endLUW(); +} + +// get clipboard into a 2-dim array of doubles +void +RideEditor::getPaste(QVector >&cells, QStringList &seps, QStringList &head, bool hasHeader) +{ + QString text = QApplication::clipboard()->text(); + + int row = 0; + int col = 0; + bool first = true; + + QString regexpStr; + regexpStr = "["; + foreach (QString sep, seps) regexpStr += sep; + regexpStr += "]"; + QRegExp sep(regexpStr); // RegExp for seperators + + foreach(QString line, text.split("\n")) { + if (line == "") continue; + + if (hasHeader && first == true) { + foreach (QString token, line.split(sep)) { + head << token; + } + } else { + cells.resize(row+1); + foreach (QString token, line.split(sep)) { + cells[row].resize(col+1); + cells[row][col] = token.toDouble(); + col++; + + // if there are more cols than in the + // heading row then set to unknown + while (hasHeader && (col+1) > head.count()) + head << "unknown"; + } + row++; + col = 0; + } + first = false; + } +} + +void +RideEditor::pasteSpecial() +{ + // paste clipboard but with a dialog to + // choose field columns and insert rather + // than overwrite or append rather than insert + + PasteSpecialDialog *paster = new PasteSpecialDialog(this); + + // center the dialog + QDesktopWidget *desktop = QApplication::desktop(); + int x = (desktop->width() - paster->size().width()) / 2; + int y = ((desktop->height() - paster->size().height()) / 2) -50; + + // move window to desired coordinates + paster->move(x,y); + paster->exec(); +} + +void +RideEditor::clear() +{ + bool changed = false; + + // Set the selected cells to zero + ride->ride()->command->startLUW("Clear cells"); + foreach (QModelIndex current, table->selectionModel()->selection().indexes()) { + changed = true; + setModelValue(current.row(), current.column(), (double)0.0); + } + ride->ride()->command->endLUW(); +} + +void +RideEditor::smooth() +{ + QString xs = xsstring(currentCell.row, model->columnType(currentCell.column)); + + // calculate smoothed value + double left = 0.0; + double right = 0.0; + if (currentCell.row > 0) left = getValue(currentCell.row-1, currentCell.column); + if (currentCell.row < (ride->ride()->dataPoints().count()-1)) right = getValue(currentCell.row+1, currentCell.column); + double value = (left+right) / 2; + + // update model + setModelValue(currentCell.row, currentCell.column, value); +} + + +//---------------------------------------------------------------------- +// Cell item delegate +//---------------------------------------------------------------------- + +// Cell editor - item delegate +CellDelegate::CellDelegate(RideEditor *rideEditor, QObject *parent) : QItemDelegate(parent), rideEditor(rideEditor) {} + +// setup editor for edit of field!! +QWidget *CellDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &, const QModelIndex &index) const +{ + // what are we editing? + RideFile::SeriesType what = rideEditor->model->columnType(index.column()); + + if (what == RideFile::secs) { + + QTimeEdit *timeEdit = new QTimeEdit(parent); + timeEdit->setDisplayFormat ("hh:mm:ss.zzz"); + connect(timeEdit, SIGNAL(editingFinished()), this, SLOT(commitAndCloseEditor())); + return timeEdit; + + } else { + QDoubleSpinBox *valueEdit = new QDoubleSpinBox(parent); + valueEdit->setDecimals(RideFile::decimalsFor(what)); + valueEdit->setMaximum(RideFile::maximumFor(what)); + valueEdit->setMinimum(RideFile::minimumFor(what)); + connect(valueEdit, SIGNAL(editingFinished()), this, SLOT(commitAndCloseEditor())); + return valueEdit; + } +} + +// user hit tab or return so save away the data to our model +void CellDelegate::commitAndCloseEditor() +{ + QDoubleSpinBox *editor = qobject_cast(sender()); + emit commitData(editor); + emit closeEditor(editor); +} + +// We don't set anything because the data is saved within the view not the model! +void CellDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + // what are we editing? + RideFile::SeriesType what = rideEditor->model->columnType(index.column()); + + if (what == RideFile::secs) { + + int seconds, msecs; + secsMsecs(index.model()->data(index, Qt::DisplayRole).toDouble(), seconds, msecs); + + QTime value = QTime(0,0,0,0).addSecs(seconds).addMSecs(msecs); + QTimeEdit *timeEdit = qobject_cast(editor); + timeEdit->setTime(value); + + } else { + QDoubleSpinBox *valueEdit = qobject_cast(editor); + double value = index.model()->data(index, Qt::DisplayRole).toString().toDouble(); + valueEdit->setValue(value); + } +} + +void CellDelegate::updateEditorGeometry(QWidget *editor, + const QStyleOptionViewItem &option, + const QModelIndex &/*index*/) const +{ + if (editor) editor->setGeometry(option.rect); +} + +// We don't set anything because the data is saved within the view not the model! +void CellDelegate::setModelData(QWidget *editor, QAbstractItemModel *, const QModelIndex &index) const +{ + // what are we editing? + RideFile::SeriesType what = rideEditor->model->columnType(index.column()); + + if (what == RideFile::secs) { + + double seconds; + QTime midnight(0,0,0,0); + QTimeEdit *timeEdit = qobject_cast(editor); + seconds = (double)midnight.secsTo(timeEdit->time()) + (double)timeEdit->time().msec() / (double)1000.00; + rideEditor->setModelValue(index.row(), index.column(), seconds); + + } else { + QDoubleSpinBox *valueEdit = qobject_cast(editor); + QString value = QString("%1").arg(valueEdit->value()); + rideEditor->setModelValue(index.row(), index.column(), valueEdit->value()); + } +} + +// anomalies are underlined in red, otherwise straight paintjob +void CellDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + // what are we editing? + RideFile::SeriesType what = rideEditor->model->columnType(index.column()); + QString value; + + if (what == RideFile::secs) { + int seconds, msecs; + secsMsecs(index.model()->data(index, Qt::DisplayRole).toDouble(), seconds, msecs); + value = QTime(0,0,0,0).addSecs(seconds).addMSecs(msecs).toString("hh:mm:ss.zzz"); + } else + value = index.model()->data(index, Qt::DisplayRole).toString(); + + // best place to update the tooltip is here, rather than whenever we update the editor + // data, since this is just before it is used... + rideEditor->model->setToolTip(index.row(), rideEditor->model->columnType(index.column()), + rideEditor->data->anomalies.value(xsstring(index.row(), rideEditor->model->columnType(index.column())),"")); + + // found items in yellow + if (rideEditor->isFound(index.row(), index.column()) == true) { + painter->fillRect(option.rect, QBrush(QColor(255,255,0))); + } + + if (rideEditor->isAnomaly(index.row(), index.column())) { + + // wavy line is a pain! + QTextDocument *meh = new QTextDocument(QString(value)); + QTextCharFormat wavy; + wavy.setUnderlineStyle(QTextCharFormat::WaveUnderline); + wavy.setUnderlineColor(Qt::red); + QTextCursor cur = meh->find(value); + cur.movePosition(QTextCursor::Start, QTextCursor::MoveAnchor); + cur.selectionStart(); + cur.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); + cur.selectionEnd(); + cur.setCharFormat(wavy); + + // only red background if not selected + //if (rideEditor->table->selectionModel()->isSelected(index) == false) + // painter->fillRect(option.rect, QBrush(QColor(255,230,230))); + + painter->save(); + painter->translate(option.rect.x(), option.rect.y()); + meh->drawContents(painter); + painter->restore(); + delete meh; + } else { + + // normal render + QStyleOptionViewItem myOption = option; + myOption.displayAlignment = Qt::AlignLeft | Qt::AlignVCenter; + drawDisplay(painter, myOption, myOption.rect, value); + drawFocus(painter, myOption, myOption.rect); + } + + // warning triangle - for high precision numbers + if (rideEditor->isTooPrecise(index.row(), index.column())) { + QPolygon triangle(3); + triangle.putPoints(0, 3, option.rect.x(), option.rect.y(), + option.rect.x()+4, option.rect.y(), + option.rect.x(), option.rect.y()+4); + painter->setBrush(QBrush(QColor(Qt::darkGreen))); + painter->setPen(QPen(QColor(Qt::darkGreen))); + painter->drawPolygon(triangle); + } +} + +//---------------------------------------------------------------------- +// handle GC Signals +//---------------------------------------------------------------------- +void +RideEditor::intervalSelected() +{ + // is it for the ride item we are editing? + if (main->currentRideItem() == ride) { + + // clear all selections + table->selectionModel()->select(QItemSelection(model->index(0,0), + model->index(ride->ride()->dataPoints().count(),model->columnCount()-1)), + QItemSelectionModel::Clear); + + // highlight selection and jump to last + foreach(QTreeWidgetItem *x, main->allIntervalItems()->treeWidget()->selectedItems()) { + + IntervalItem *current = (IntervalItem*)x; + + if (current != NULL && current->isSelected() == true) { + + // what is the first dataPoint index for this interval? + int start = ride->ride()->timeIndex(current->start); + int end = ride->ride()->timeIndex(current->stop); + + // select all the rows + table->selectionModel()->clearSelection(); + table->selectionModel()->setCurrentIndex(model->index(start,0), QItemSelectionModel::Select); + table->selectionModel()->select(QItemSelection(model->index(start,0), + model->index(end,model->columnCount()-1)), + QItemSelectionModel::Select); + } + } + } +} + +void +RideEditor::rideSelected() +{ + RideItem *current = main->rideItem(); + if (!current) return; + + ride = current; + + // get/or setup ridefile state data + if (ride->ride()->editorData() == NULL) { + data = new EditorData; + ride->ride()->setEditorData(data); + } else { + data = ride->ride()->editorData(); + data->found.clear(); // search is not active, so clear + } + + model->setRide(ride->ride()); + + // reset the save icon on the toolbar + if (ride->isDirty()) saveAct->setEnabled(true); + else saveAct->setEnabled(false); + + // connect the ride command signals so we can + // set/reset undo/redo + static QPointer connection = NULL; + if (connection) { + disconnect(connection, SIGNAL(endCommand(bool,RideCommand*)), this, SLOT(endCommand(bool,RideCommand*))); + disconnect(connection, SIGNAL(beginCommand(bool,RideCommand*)), this, SLOT(beginCommand(bool,RideCommand*))); + } + connection = ride->ride()->command; + connect(connection, SIGNAL(endCommand(bool,RideCommand*)), this, SLOT(endCommand(bool,RideCommand*))); + connect(connection, SIGNAL(beginCommand(bool,RideCommand*)), this, SLOT(beginCommand(bool,RideCommand*))); + + // lets set them anyway + if (ride->ride()->command->redoCount() == 0) redoAct->setEnabled(false); + else redoAct->setEnabled(true); + if (ride->ride()->command->undoCount() == 0) undoAct->setEnabled(false); + else undoAct->setEnabled(true); + + // look for anomalies + check(); +} + +// We update the current selection on the table view +// to reflect the actions performed on the data. This +// is especially relevant when 'redo'ing a command +// since it signposts the user to the change that has +// been applied and makes the UI feel more 'natural' +// +void +RideEditor::beginCommand(bool, RideCommand *cmd) +{ + // when executing a Logical Unit of Work we + // highlight sells as we go, rather than + // clearing the current selection and highlighting + // only those cells updated in the current command + // we set inLUW to let endCommand (below) know that + // we are in an LUW + if (cmd->type == RideCommand::LUW) { + inLUW = true; + + // redo needs to clear the current selection + // since we do not clear selections during + // a LUW in endCommand below. We do not clear + // for the first 'do' because the current + // selection is being used to identify which + // cells to operate upon. + if (cmd->docount) { + itemselection.clear(); + table->selectionModel()->clearSelection(); + } + } +} + +// Once the command has been executed we update the +// selection model to highlight the changes to the +// user. We also need to update the EditorData maps +// to reflect changes when rows are added or removed +void +RideEditor::endCommand(bool undo, RideCommand *cmd) +{ + + // Update the undo/redo toolbar icons + if (ride->ride()->command->redoCount() == 0) redoAct->setEnabled(false); + else redoAct->setEnabled(true); + if (ride->ride()->command->undoCount() == 0) undoAct->setEnabled(false); + else undoAct->setEnabled(true); + + // update the selection model when a command has been executed + switch (cmd->type) { + + case RideCommand::SetPointValue: + { + SetPointValueCommand *spv = (SetPointValueCommand*)cmd; + + // move cursor to point updated + QModelIndex cursor = model->index(spv->row, model->columnFor(spv->series)); + // NOTE: This is to circumvent a performance issue with multiple + // calls to setCurrentIndex XXX still TODO... + if (inLUW) { // remember and do it at the end -- otherwise major performance impact!! + itemselection << cursor; + } else { + table->selectionModel()->select(cursor, QItemSelectionModel::SelectCurrent); + table->selectionModel()->setCurrentIndex(cursor, inLUW ? QItemSelectionModel::Select : + QItemSelectionModel::SelectCurrent); + } + + break; + } + case RideCommand::InsertPoint: + { + InsertPointCommand *ip = (InsertPointCommand *)cmd; + if (undo) { // deleted this row... + data->deleteRows(ip->row, 1); + } else { + data->insertRows(ip->row, 1); + } + break; + } + case RideCommand::DeletePoint: + { + DeletePointCommand *dp = (DeletePointCommand *)cmd; + if (undo) { + // clear current + if (!inLUW) table->selectionModel()->clearSelection(); + + // undo delete brings in a row to highlight + QModelIndex topleft = model->index(dp->row, 0); + QItemSelection highlight(topleft, model->index(dp->row, model->headings().count()-1)); + + // highlight the rows brought back in + table->selectionModel()->setCurrentIndex(topleft, QItemSelectionModel::Select); + table->selectionModel()->select(highlight, inLUW ? QItemSelectionModel::Select : + QItemSelectionModel::SelectCurrent); + + // update the EditorData maps + data->insertRows(dp->row, 1); + } else { + // update the EditorData maps + data->deleteRows(dp->row, 1); + } + break; + } + case RideCommand::DeletePoints: + { + DeletePointsCommand *dp = (DeletePointsCommand *)cmd; + if (undo) { + + // clear current + if (!inLUW) table->selectionModel()->clearSelection(); + + // highlight the rows brought back in + // undo delete brings in a row to highlight + QModelIndex topleft = model->index(dp->row, 0); + QItemSelection highlight(topleft, model->index(dp->row+dp->count-1, model->headings().count()-1)); + + // highlight the rows brought back in + table->selectionModel()->setCurrentIndex(topleft, QItemSelectionModel::Select); + table->selectionModel()->select(highlight, inLUW ? QItemSelectionModel::Select : + QItemSelectionModel::SelectCurrent); + // update the EditorData maps + data->insertRows(dp->row, dp->count); + } else { + // update the EditorData maps + data->deleteRows(dp->row, dp->count); + } + break; + } + case RideCommand::AppendPoints: + if (!undo) { + + // clear current + if (!inLUW) table->selectionModel()->clearSelection(); + + // show the user where the rows went + AppendPointsCommand *ap = (AppendPointsCommand*)cmd; + QModelIndex topleft = model->index(ap->row, 0); + QItemSelection highlight(topleft, model->index(ap->row+ap->count-1, model->headings().count()-1)); + + // move cursor and highligth all the rows + table->selectionModel()->setCurrentIndex(topleft, QItemSelectionModel::Select); + table->selectionModel()->select(highlight, inLUW ? QItemSelectionModel::Select : + QItemSelectionModel::SelectCurrent); + } + break; + + case RideCommand::SetDataPresent: + { + // clear current + if (!inLUW) table->selectionModel()->clearSelection(); + + // show the user where the rows went + SetDataPresentCommand *sp = (SetDataPresentCommand*)cmd; + + // highlight row just arrived + if ((sp->oldvalue == true && undo == true) || (sp->oldvalue == false && undo == false)) { + QModelIndex top = model->index(0, model->columnFor(sp->series)); + QModelIndex bottom = model->index(ride->ride()->dataPoints().count()-1, model->columnFor(sp->series)); + QItemSelection highlight(top,bottom); + + // move cursor and highligth all the rows + if (!inLUW) { + table->selectionModel()->clearSelection(); + table->selectionModel()->setCurrentIndex(top, QItemSelectionModel::Select); + } + table->selectionModel()->select(highlight, inLUW ? QItemSelectionModel::Select : + QItemSelectionModel::SelectCurrent); + } + + } + break; + + case RideCommand::LUW: + { + inLUW = false; + + // kinda crap, but QItemSelection::merge was painfully slow + // and we cannot guarantee that a LUW will be in a contiguous + // range since it collects lots of atomic actions + int top=99999999, left=99999999, right=-9999999, bottom=-999999; + foreach (QModelIndex index, itemselection) { + if (index.row() < top) top = index.row(); + if (index.row() > bottom) bottom = index.row(); + if (index.column() < left) left = index.column(); + if (index.column() > right) right = index.column(); + } + itemselection.clear(); + table->selectionModel()->select(QItemSelection(model->index(top,left), + model->index(bottom,right)), QItemSelectionModel::Select); + } + break; + + default: + break; + } + if (!inLUW) check(); // refresh the anomalies... +} + +void +RideEditor::rideClean() +{ + // the file was saved / reverted + saveAct->setEnabled(false); +} + +void +RideEditor::rideDirty() +{ + // the file was updated + saveAct->setEnabled(true); +} + +//---------------------------------------------------------------------- +// EditorData functions +//---------------------------------------------------------------------- +void +EditorData::deleteRows(int row, int count) +{ + + // anomalies + if (anomalies.count()) { + QMutableMapIterator a(anomalies); + QMap newa; // updated QMap + while (a.hasNext()) { + a.next(); + + int crow; + RideFile::SeriesType series; + unxsstring(a.key(), crow, series); + + if (crow >= row && crow <= (row+count-1)) { + // do nothing - i.e. don't copy across - it is zapped + ; + } else if (crow > (row+count-1)) { + crow -= count; + newa.insert(xsstring(crow,series), a.value()); + } else { + newa.insert(a.key(), a.value()); + } + } + anomalies = newa; // replace with resynced values + } + + // found + if (found.count()) { + QMutableMapIterator r(found); + QMap newr; // updated QMap + while (r.hasNext()) { + r.next(); + + int crow; + RideFile::SeriesType series; + unxsstring(r.key(), crow, series); + + if (crow >= row && crow <= (row+count-1)) { + // do nothing - i.e. don't copy across + ; + } else if (crow > (row+count-1)) { + crow -= count; + newr.insert(xsstring(crow,series), r.value()); + } else { + newr.insert(r.key(), r.value()); + } + } + found = newr; // replace with resynced values + } +} + +void +EditorData::deleteSeries(RideFile::SeriesType series) +{ + + // anomalies + if (anomalies.count()) { + QMutableMapIterator a(anomalies); + QMap newa; // updated QMap + while (a.hasNext()) { + a.next(); + + int crow; + RideFile::SeriesType cseries; + unxsstring(a.key(), crow, cseries); + + if (cseries == series) { + // do nothing - i.e. don't copy across - it is zapped + ; + } else { + newa.insert(a.key(), a.value()); + } + } + anomalies = newa; // replace with resynced values + } + + // found + if (found.count()) { + QMutableMapIterator r(found); + QMap newr; // updated QMap + while (r.hasNext()) { + r.next(); + + int crow; + RideFile::SeriesType cseries; + unxsstring(r.key(), crow, cseries); + + if (cseries == series) { + // do nothing - i.e. don't copy across + ; + } else { + newr.insert(r.key(), r.value()); + } + } + found = newr; // replace with resynced values + } +} + +void +EditorData::insertRows(int row, int count) +{ + + // anomalies + if (anomalies.count()) { + QMutableMapIterator a(anomalies); + QMap newa; // updated QMap + while (a.hasNext()) { + a.next(); + + int crow; + RideFile::SeriesType series; + unxsstring(a.key(), crow, series); + + if (crow > row) { + crow += count; + newa.insert(xsstring(crow,series), a.value()); + } else { + newa.insert(a.key(), a.value()); + } + } + anomalies = newa; // replace with resynced values + } + + // found + if (found.count()) { + QMutableMapIterator r(found); + QMap newr; // updated QMap + + while (r.hasNext()) { + r.next(); + + int crow; + RideFile::SeriesType series; + unxsstring(r.key(), crow, series); + + if (crow > row) { + crow += count; + newr.insert(xsstring(crow,series), r.value()); + } else { + newr.insert(r.key(), r.value()); + } + } + found = newr; // replace with resynced values + } +} + +//---------------------------------------------------------------------- +// Toolbar dialogs +//---------------------------------------------------------------------- + +// +// Find Dialog +// +FindDialog::FindDialog(RideEditor *rideEditor, QWidget *parent) : QDialog(parent), rideEditor(rideEditor) +{ + // setup the basic window settings; nonmodal, ontop and delete on close + setWindowTitle("Search"); + setAttribute(Qt::WA_DeleteOnClose); + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint | Qt::Tool); + + + // create UI components + QLabel *look = new QLabel(tr("Find values"), this); + type = new QComboBox(this); + type->addItem(tr("Between")); + type->addItem(tr("Not Between")); + type->addItem(tr("Greater Than")); + type->addItem(tr("Less Than")); + type->addItem(tr("Equal To")); + type->addItem(tr("Not Equal To")); + + andLabel = new QLabel(tr("and"), this); + from = new QDoubleSpinBox(this); + from->setMinimum(-9999999); + from->setMaximum(9999999); + from->setDecimals(5); + from->setSingleStep(0.00001); + + to = new QDoubleSpinBox(this); + to->setMinimum(-9999999); + to->setMaximum(9999999); + to->setDecimals(5); + to->setSingleStep(0.00001); + + // which columns? + foreach (QString heading, rideEditor->model->headings()) { + QCheckBox *add = new QCheckBox(heading); + if (heading == tr("Power")) + add->setChecked(true); + else + add->setChecked(false); + channels << add; + } + + // buttons + findButton = new QPushButton(tr("Find")); + closeButton = new QPushButton(tr("Close")); + + // results + resultsTable = new QTableWidget(this); + resultsTable->setColumnCount(4); + resultsTable->setColumnHidden(3, true); + resultsTable->setSortingEnabled(true); + QStringList header; + header << "Time" << "Column" << "Value"; + resultsTable->setHorizontalHeaderLabels(header); + resultsTable->verticalHeader()->hide(); + resultsTable->setShowGrid(false); + resultsTable->setSelectionMode(QAbstractItemView::SingleSelection); + resultsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + + // layout the widget + QVBoxLayout *mainLayout = new QVBoxLayout(this); + QGridLayout *criteria = new QGridLayout; + criteria->setColumnStretch(0,1); + criteria->setColumnStretch(1,2); + + criteria->addWidget(look, 0,0, Qt::AlignLeft|Qt::AlignTop); + criteria->addWidget(type, 0,1, Qt::AlignLeft|Qt::AlignTop); + criteria->addWidget(from, 1,1, Qt::AlignLeft|Qt::AlignTop); + criteria->addWidget(andLabel, 2,0, Qt::AlignRight|Qt::AlignTop); + criteria->addWidget(to, 2,1, Qt::AlignLeft|Qt::AlignTop); + mainLayout->addLayout(criteria); + + QGridLayout *chans = new QGridLayout; + mainLayout->addLayout(chans); + + int row =0; + int col =0; + foreach (QCheckBox *check, channels) { + chans->addWidget(check, row,col); + if (++col > 2) { col =0; row++; } + } + + QHBoxLayout *execute = new QHBoxLayout; + execute->addStretch(); + execute->addWidget(findButton); + mainLayout->addLayout(execute); + + mainLayout->addWidget(resultsTable); + + QHBoxLayout *closer = new QHBoxLayout; + closer->addStretch(); + closer->addWidget(closeButton); + mainLayout->addLayout(closer); + + setLayout(mainLayout); + + connect(type, SIGNAL(currentIndexChanged(int)), this, SLOT(typeChanged(int))); + connect(findButton, SIGNAL(clicked()), this, SLOT(find())); + connect(closeButton, SIGNAL(clicked()), this, SLOT(accept())); + connect(resultsTable, SIGNAL(itemSelectionChanged()), this, SLOT(selection())); + + // refresh when data changes... + connect(rideEditor->ride->ride()->command, SIGNAL(endCommand(bool,RideCommand*)), this, SLOT(dataChanged())); +} + +FindDialog::~FindDialog() +{ + rideEditor->data->found.clear(); + clearResultsTable(); + rideEditor->model->forceRedraw(); +} + +void +FindDialog::typeChanged(int index) +{ + // 0 and 1 are range based the rest are single value + if (index < 2) { + from->show(); + to->show(); + andLabel->show(); + } else { + to->hide(); + andLabel->hide(); + } +} + +void +FindDialog::find() +{ + // are we looking anywhere? + bool search = false; + foreach (QCheckBox *c, channels) if (c->isChecked()) search = true; + if (search == false) return; + + // ok something to do then... + rideEditor->data->found.clear(); + clearResultsTable(); + + for (int i=0; i< rideEditor->ride->ride()->dataPoints().count(); i++) { + // for each selected channel, get the value and + // see if it matches + + foreach (QCheckBox *c, channels) { + + if (c->isChecked()) { + + // which Column? + int col = rideEditor->model->headings().indexOf(c->text()); + if (col >= 0) { + + double value = rideEditor->getValue(i, col); + + bool match = false; + switch(type->currentIndex()) { + + case 0 : // between + if (value >= from->value() && value <= to->value()) match = true; + break; + + case 1 : // not between + if (!(value >= from->value() && value <= to->value())) match = true; + break; + + case 2 : // greater than + if (value > from->value()) match = true; + break; + + case 3 : // less than + if (value < from->value()) match = true; + break; + + case 4 : // matches + if (value == from->value()) match = true; + break; + + case 5 : // not equal + if (value != from->value()) match = true; + break; + + } + + if (match == true) { + + // highlight on the table + rideEditor->data->found.insert(xsstring(i,rideEditor->model->columnType(col)), QString("%1").arg(value)); + + } + } + } + } + } + + dataChanged(); // update results table and redraw + + rideEditor->model->forceRedraw(); +} + +void +FindDialog::dataChanged() +{ + // a column or row was inserted/deleted + // we need to refresh the results table + // to reflect the new values + clearResultsTable(); + + // do not be tempted to removed the following two lines! + // by setting the row count to zero the item selection + // is cleared properly and fixes a crash in the line + // marked ZZZZ below. This only occurs when the current item + // selected is greater than the new row count. + resultsTable->setRowCount(0); // <<< fixes crash at ZZZZ + resultsTable->setColumnCount(0); + + resultsTable->setRowCount(rideEditor->data->found.count()); // <<< ZZZZ + resultsTable->setColumnCount(4); + resultsTable->setColumnHidden(3, true); // has start xystring + QMapIterator f(rideEditor->data->found); + + resultsTable->setSortingEnabled(false);// see QT Bug QTBUG-7483 + + int counter =0; + while (f.hasNext()) { + + f.next(); + + int row; + RideFile::SeriesType series; + unxsstring(f.key(), row, series); + + // time -- format correctly... held as a double in the model + int seconds, msecs; + secsMsecs(rideEditor->model->getValue(row,0), seconds, msecs); + QString value = QTime(0,0,0,0).addSecs(seconds).addMSecs(msecs).toString("hh:mm:ss.zzz"); + + QTableWidgetItem *t = new QTableWidgetItem; + t->setText(value); + t->setFlags(t->flags() & (~Qt::ItemIsEditable)); + resultsTable->setItem(counter, 0, t); + + // channel + t = new QTableWidgetItem; + t->setText(RideFile::seriesName(series)); + t->setFlags(t->flags() & (~Qt::ItemIsEditable)); + resultsTable->setItem(counter, 1, t); + + // value + t = new QTableWidgetItem; + t->setText(QString("%1").arg(rideEditor->ride->ride()->getPointValue(row,series))); + t->setFlags(t->flags() & (~Qt::ItemIsEditable)); + resultsTable->setItem(counter, 2, t); + + // xs for selection + t = new QTableWidgetItem; + t->setText(f.key()); + t->setFlags(t->flags() & (~Qt::ItemIsEditable)); + resultsTable->setItem(counter, 3, t); + + resultsTable->setRowHeight(counter, 20); + + counter++; + } + resultsTable->setSortingEnabled(true);// see QT Bug QTBUG-7483 + QStringList header; + header << "Time" << "Column" << "Value"; + resultsTable->setHorizontalHeaderLabels(header); +} + +void +FindDialog::close() +{ + accept(); +} + +void +FindDialog::selection() +{ + if (resultsTable->currentRow() < 0) return; + + // jump to the found item in the main table + int row; + RideFile::SeriesType series; + unxsstring(resultsTable->item(resultsTable->currentRow(), 3)->text(), row, series); + rideEditor->table->setCurrentIndex(rideEditor->model->index(row,rideEditor->model->columnFor(series))); +} + +void +FindDialog::clearResultsTable() +{ + resultsTable->selectionModel()->clearSelection(); + // zap the 3 main cols and two hidden ones + for (int i=0; irowCount(); i++) { + for (int j=0; jcolumnCount(); j++) + delete resultsTable->takeItem(i,j); + } +} + +// +// Paste Special Dialog +// +PasteSpecialDialog::PasteSpecialDialog(RideEditor *rideEditor, QWidget *parent) : QDialog(parent), rideEditor(rideEditor) +{ + // setup the basic window settings; nonmodal, ontop and delete on close + setWindowTitle("Paste Special"); + setAttribute(Qt::WA_DeleteOnClose); + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint | Qt::Tool); + + QVBoxLayout *mainLayout = new QVBoxLayout(this); + + // create the widgets + mode = new QGroupBox(tr("Paste mode")); + separators = new QGroupBox(tr("Separator options")); + contents = new QGroupBox(tr("Columns")); + + append = new QRadioButton(tr("Append")); + overwrite = new QRadioButton(tr("Overwrite")); + + QVBoxLayout *modeLayout = new QVBoxLayout; + modeLayout->addWidget(append); + modeLayout->addWidget(overwrite); + mode->setLayout(modeLayout); + mode->setFlat(false); + append->setChecked(true); + + hasHeader = new QCheckBox(tr("First line has headings")); + tab = new QCheckBox(tr("Tab")); + tab->setChecked(true); + comma = new QCheckBox(tr("Comma")); + semi = new QCheckBox(tr("Semi-colon")); + space = new QCheckBox(tr("Space")); + other = new QCheckBox(tr("Other")); + otherText = new QLineEdit; + QHBoxLayout *otherLayout = new QHBoxLayout; + otherLayout->addWidget(other); + otherLayout->addWidget(otherText); + otherLayout->addStretch(); + + QGridLayout *sepLayout = new QGridLayout; + sepLayout->addWidget(hasHeader, 0,0); + sepLayout->addWidget(tab, 1,0); + sepLayout->addWidget(comma, 1,1); + sepLayout->addWidget(semi, 1,2); + sepLayout->addWidget(space, 1,3); + sepLayout->addLayout(otherLayout, 2,0,1,4); + + separators->setLayout(sepLayout); + separators->setFlat(false); + + // what we got? + seps << "\t"; + rideEditor->getPaste(cells, seps, sourceHeadings, false); + + resultsTable = new QTableView(this); + resultsTable->setSelectionMode(QAbstractItemView::SingleSelection); + resultsTable->setSelectionBehavior(QAbstractItemView::SelectColumns); + QFont font; + font.setPointSize(font.pointSize()-2); // smaller please + resultsTable->setFont(font); + resultsTable->verticalHeader()->setDefaultSectionSize(QFontMetrics(font).height()+2); + + model = new QStandardItemModel; + resultsTable->setModel(model); + setResultsTable(); + + // column selector + QHBoxLayout *selectorLayout = new QHBoxLayout; + QLabel *selectLabel = new QLabel(tr("Column Type")); + columnSelect = new QComboBox; + columnSelect->addItem("Ignore"); + foreach (QString name, rideEditor->model->headings()) + columnSelect->addItem(name); + selectorLayout->addWidget(selectLabel); + selectorLayout->addWidget(columnSelect); + selectorLayout->addStretch(); + + // contents layout + QVBoxLayout *contentsLayout = new QVBoxLayout; + contentsLayout->addLayout(selectorLayout); + contentsLayout->addWidget(resultsTable); + contents->setLayout(contentsLayout); + contents->setFlat(false); + +#if 0 + QDoubleSpinBox *atRow; + QComboBox *textDelimeter; +#endif + + okButton = new QPushButton(tr("OK")); + cancelButton = new QPushButton(tr("Cancel")); + QHBoxLayout *buttons = new QHBoxLayout; + buttons->addStretch(); + buttons->addWidget(cancelButton); + buttons->addWidget(okButton); + + // layout the widgets + QGridLayout *widgetLayout = new QGridLayout; + widgetLayout->addWidget(mode, 0,0); + widgetLayout->addWidget(separators,0,1); + widgetLayout->addWidget(contents,1,0,1,2); + widgetLayout->setRowStretch(0,1); + widgetLayout->setRowStretch(1,4); + + mainLayout->addLayout(widgetLayout); + mainLayout->addLayout(buttons); + + // set size hint + setMinimumHeight(300); + setMinimumWidth(500); + + connect(okButton, SIGNAL(clicked()), this, SLOT(okClicked())); + connect(cancelButton, SIGNAL(clicked()), this, SLOT(cancelClicked())); + connect(columnSelect, SIGNAL(currentIndexChanged(int)), this, SLOT(columnChanged())); + connect(resultsTable->selectionModel(), + SIGNAL(selectionChanged(const QItemSelection &, const QItemSelection &)), + this, SLOT(setColumnSelect())); + connect(hasHeader, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); + connect(tab, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); + connect(comma, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); + connect(semi, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); + connect(space, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); + connect(other, SIGNAL(stateChanged(int)), this, SLOT(sepsChanged())); +} + +void +PasteSpecialDialog::clearResultsTable() +{ + model->clear(); +} + +void +PasteSpecialDialog::setResultsTable() +{ + boost::shared_ptr settings = GetApplicationSettings(); + + model->clear(); + headings.clear(); + + // is it like what was just copied and no headings line? + if (hasHeader->isChecked() == false && cells[0].count() == rideEditor->copyHeadings.count()) { + headings = rideEditor->copyHeadings; + } else { + for (int i=0; hasHeader->isChecked() ? (iisChecked() == true) { + // have we mapped this before? + QString lookup = "colmap/" + sourceHeadings[i]; + QString mapto = settings->value(lookup, "Ignore").toString(); + // is this an available heading tho? + if (columnSelect->findText(mapto) != -1) { + headings << mapto; + } else { + headings << "Ignore"; + } + } else { + headings << "Ignore"; + } + } + } + + model->setRowCount(cells.count() < 50 ? cells.count() : 50); + model->setColumnCount(cells[0].count()); + model->setHorizontalHeaderLabels(headings); + + // just setup the first 50 rows + for (int row=0; row < 50 && row < cells.count(); row++) { + for (int col=0; col < cells[row].count(); col++) { + // add value + model->setItem(row, col, new QStandardItem(QString("%1").arg(cells[row][col]))); + } + } +} + +PasteSpecialDialog::~PasteSpecialDialog() +{ + clearResultsTable(); + rideEditor->model->forceRedraw(); +} + +void +PasteSpecialDialog::okClicked() +{ + // headings has the headings for each column + // with "Ignore" set if we are to igmore it + // cells contains all the actual data from the + // buffer. + // We have three modes; (1) insert means add new + // samples at the top of the current selection + // which will mean all time/distance offsets for + // the remaining rows will be increased and the + // time/distance for the inserted rows will start + // after the previous row time/distance + // (2) append will add all samples to the tail of + // the ride file with time/distance offset from + // the last sample currently in the ride + // (3) overwrite will change the current samples to + // use the non-ignored values, including time/distance + // which may well create out-of-sync values. Which + // is why we do an anomaly check at the end. + + // add these to the pasted rows + double timeOffset, distanceOffset; + int where = rideEditor->ride->ride()->dataPoints().count(); + + if (append->isChecked() && rideEditor->ride->ride()->dataPoints().count()) { + timeOffset = rideEditor->ride->ride()->dataPoints().last()->secs; + distanceOffset = rideEditor->ride->ride()->dataPoints().last()->km; + } + + // if we are inserting or appending lets create an array of rows to insert + if (append->isChecked()) { + + QVector newRows; + + for (int row=0; row < cells.count(); row++) { + + struct RideFilePoint newrow; + newrow.secs = timeOffset; // in case it is being ignore or not available + newrow.km = distanceOffset; // in case it is being ignored or not available + + for (int col=0; col < cells[row].count(); col++) { + if (headings[col] == tr("Ignore")) continue; + + double value; + if (headings[col] == tr("Time")) value = cells[row][col] + timeOffset; + else if (headings[col] == tr("Distance")) value = cells[row][col] + distanceOffset; + else value = cells[row][col]; + + // update the relevant value in the new dataPoint based upon the heading... + if (headings[col] == tr("Time")) newrow.secs = value; + if (headings[col] == tr("Distance")) newrow.km = value; + if (headings[col] == tr("Speed")) newrow.kph = value; + if (headings[col] == tr("Cadence")) newrow.cad = value; + if (headings[col] == tr("Power")) newrow.watts = value; + if (headings[col] == tr("Heartrate")) newrow.hr = value; + if (headings[col] == tr("Torque")) newrow.nm = value; + if (headings[col] == tr("Latitude")) newrow.lat = value; + if (headings[col] == tr("Longitude")) newrow.lon = value; + if (headings[col] == tr("Altitude")) newrow.alt = value; + if (headings[col] == tr("Headwind")) newrow.headwind = value; + if (headings[col] == tr("Interval")) newrow.interval = value; + } + + // add to the list + newRows << newrow; + } + + // ok. we are good to go, so overwrite target with source + rideEditor->ride->ride()->command->startLUW("Paste Special"); + + // now we have an array add to dataPoints + rideEditor->model->appendRows(newRows); + + // highlight the affected cells -- good UI for paste, since it might + // update offscreen (esp. true for our paste append) + QItemSelection highlight = QItemSelection(rideEditor->model->index(where, 0), + rideEditor->model->index(where+newRows.count()-1, rideEditor->model->headings().count()-1)); + + // our job is done. + rideEditor->ride->ride()->command->endLUW(); + + } else { + + // *** The paste special overwrite function is somewhat + // *** "clever", it will only overwrite columns that were + // *** selected in the dialog box amd will only overwrite + // *** existing rows. This makes the paste operation quite + // *** "complicated". We do quite a lot of checks to make + // *** sure the user really mean't to do this... + + // get the target selection + QList selection = rideEditor->table->selectionModel()->selection().indexes(); + if (selection.count() == 0) { + // wrong size + QMessageBox oops(QMessageBox::Critical, tr("Paste error"), + tr("Please select target cell or cells to paste values into.")); + oops.exec(); + accept(); + return; + } + + // to make code more readable, and with an eye to refactoring + // use these vars to describe the range selected in the table + // the target we will paste to (i.e. we may truncate) and the + // range available in the clipboard + struct range { int row, column, rows, columns; } selected, target, source; + bool norange = selection.count() == 1 ? true : false; + bool truncate = false, partial = false; + + // what is selected in the selection model? + selected.row = selection.first().row(); + selected.column = selection.first().column(); + selected.rows = selection.last().row() - selection.first().row() + 1; + selected.columns = selection.last().column() - selection.first().column() + 1; + if (norange) { + // Single cell selected for paste means 'from here' not + // 'into here'. So from here to end of the row is selected + // because we check by series type not column number when + // the paste is performed we will not go out of bounds + selected.columns = rideEditor->model->headings().count() - selected.column; + } + + // what is in the clipboard? + source.row = 0; // not defined, obviously + source.column = 0; + source.rows = cells.count(); + source.columns = 0; + foreach(QString heading, headings) + if (heading != tr("Ignore")) source.columns++; + + // so what is the target? + target.row = selected.row; + target.column = selected.column; + if (norange) { + target.rows = source.rows; + target.columns = source.columns; + } else { + target.rows = selected.rows; + target.columns = selected.columns; + } + // out of bounds for ride? + if (target.row + target.rows > rideEditor->ride->ride()->dataPoints().count()) { + truncate = true; + target.rows = rideEditor->ride->ride()->dataPoints().count() - target.row; + } + // selection smaller than clipboard? + if (source.rows > target.rows) { + truncate = true; + } + // partially fill selected rows ? + if (source.rows < target.rows) { + partial = true; + target.rows = source.rows; + } + // out of bounds for columns? + if (target.column + target.columns - 1 > rideEditor->model->headings().count()) { + truncate = true; + target.columns = rideEditor->model->headings().count() - target.column; + } + // selection smaller than clipboard? + if (source.columns > target.columns) { + truncate = true; + } + // partially fill columnss ? + if (source.columns < target.columns) { + partial = true; + target.columns = source.columns; + } + + // + // Now we have calculated the source and target, lets + // make sure the user agrees ... + // + if (truncate || partial) { + + // we are going to truncate + QMessageBox confirm(QMessageBox::Question, tr("Copy/Paste Mismatch"), + tr("The selected range and available data have " + "different sizes, some data may be lost.\n\n" + "Do you want to continue?"), + QMessageBox::Ok | QMessageBox::Cancel); + + if ((confirm.exec() & QMessageBox::Cancel) != 0) { + accept(); + return; // accept doesn't return. + } + } + + // ok. we are good to go, so overwrite target with source + rideEditor->ride->ride()->command->startLUW("Paste Special"); + + for (int i = 0; i < target.rows; i++) { + + for (int j = 0; j < target.columns; j++) { + + // target column type... + RideFile::SeriesType what = rideEditor->model->columnType(target.column + j); + + // do we have that? + int sourceSeries = headings.indexOf(RideFile::seriesName(what)); + if (sourceSeries != -1) // YES, we have some + rideEditor->ride->ride()->command->setPointValue(target.row+i, what, cells[i][sourceSeries]); + } + } + + // highlight what we did + QItemSelection highlight = QItemSelection(rideEditor->model->index(target.row,target.column), + rideEditor->model->index(target.row+target.rows-1, target.column+target.columns-1)); + + // all done. + rideEditor->ride->ride()->command->endLUW(); + } + accept(); +} + +void +PasteSpecialDialog::cancelClicked() +{ + reject(); +} + +void +PasteSpecialDialog::sepsChanged() +{ + seps.clear(); + cells.clear(); + sourceHeadings.clear(); + + if (tab->isChecked()) seps << "\t"; + if (comma->isChecked()) seps << ","; + if (semi->isChecked()) seps << ";"; + if (space->isChecked()) seps << " "; + if (other->isChecked()) seps << otherText->text(); + + rideEditor->getPaste(cells, seps, sourceHeadings, hasHeader->isChecked()); + setResultsTable(); +} + +void +PasteSpecialDialog::setColumnSelect() +{ + QList selection = resultsTable->selectionModel()->selection().indexes(); + if (selection.count() == 0) return; + int column = selection[0].column(); // which column? + active = true; + columnSelect->setCurrentIndex(columnSelect->findText(headings[column])); + active = false; + +} + +void +PasteSpecialDialog::columnChanged() +{ + if (active) return; + + // is anything selected? + QList selection = resultsTable->selectionModel()->selection().indexes(); + if (selection.count() == 0) return; + + // set column heading + int column = selection[0].column(); + QString text = columnSelect->itemText(columnSelect->currentIndex()); + + // set the headings string + headings[column] = text; + + // now update the results table + model->setHorizontalHeaderLabels(headings); + + // lets remember this mapping if its to a source header + if (hasHeader->isChecked() && headings[column] != "Ignore") { + boost::shared_ptr settings = GetApplicationSettings(); + QString lookup = "colmap/" + sourceHeadings[column]; + settings->setValue(lookup, headings[column]); + } +} diff --git a/src/RideEditor.h b/src/RideEditor.h new file mode 100644 index 000000000..1b5d0a22a --- /dev/null +++ b/src/RideEditor.h @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2010 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 + */ + +#ifndef _GC_RideEditor_h +#define _GC_RideEditor_h 1 + +#include "MainWindow.h" +#include "RideItem.h" +#include "RideFile.h" +#include "RideFileCommand.h" +#include "RideFileTableModel.h" +#include + +class EditorData; +class CellDelegate; +class RideModel; +class FindDialog; +class PasteSpecialDialog; + +class RideEditor : public QWidget +{ + Q_OBJECT + + friend class ::FindDialog; + friend class ::PasteSpecialDialog; + friend class ::CellDelegate; + + public: + + RideEditor(MainWindow *); + + // item delegate uses this + QTableView *table; + + // read/write the model + void setModelValue(int row, int column, double value); + double getValue(int row, int column); + + // setup context menu + void stdContextMenu (QMenu *, const QPoint &pos); + bool isAnomaly(int x, int y); + bool isEdited(int x, int y); + bool isFound(int x, int y); + bool isTooPrecise(int x, int y); + bool isRowSelected(); + bool isColumnSelected(); + + signals: + void insertRows(); + void insertColumns(); + void deleteRows(); + void deleteColumns(); + + public slots: + + // toolbar functions + void saveFile(); + void undo(); + void redo(); + void find(); + void check(); + + // context menu functions + void smooth(); + void cut(); + void delColumn(); + void insColumn(QString); + void delRow(); + void insRow(); + void copy(); + void paste(); + void pasteSpecial(); + void clear(); + + // trap QTableView signals + bool eventFilter(QObject *, QEvent *); + void cellMenu(const QPoint &); + void borderMenu(const QPoint &); + + // ride command + void beginCommand(bool,RideCommand*); + void endCommand(bool,RideCommand*); + + // GC signals + void configChanged(); + void rideSelected(); + void intervalSelected(); + void rideDirty(); + void rideClean(); + + // util + void getPaste(QVector >&cells, + QStringList &seps, QStringList &head, bool); + + protected: + EditorData *data; + RideItem *ride; + RideFileTableModel *model; + QStringList copyHeadings; + + private: + MainWindow *main; + + bool inLUW; + QList itemselection; + + double DPFSmax, DPFSvariance; + + QList whatColumns(); + QSignalMapper *colMapper; + + QToolBar *toolbar; + QAction *saveAct, *undoAct, *redoAct, + *searchAct, *checkAct; + + // state data + struct { int row, column; } currentCell; +}; + +class EditorData +{ + public: + QMap anomalies; + QMap found; + + // when underlying data is modified + // these are called to adjust references + void deleteRows(int row, int count); + void insertRows(int row, int count); + void deleteSeries(RideFile::SeriesType); +}; + +class RideModel : public QStandardItemModel +{ + public: + RideModel(int rows, int columns) : QStandardItemModel(rows, columns) {} + void forceRedraw(const QModelIndex&x, const QModelIndex&y) { emit dataChanged(x,y); } +}; + +class CellDelegate : public QItemDelegate +{ + Q_OBJECT + +public: + CellDelegate(RideEditor *, QObject *parent = 0); + + // setup editor in the cell - QDoubleSpinBox + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const; + + // Fetch data from model ready for editing + void setEditorData(QWidget *editor, const QModelIndex &index) const; + + // Save data back to model after editing + void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const; + + // override stanard painter to underline anomalies in red + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const; + + void updateEditorGeometry(QWidget *editor, + const QStyleOptionViewItem &option, + const QModelIndex &index) const; + +private slots: + + void commitAndCloseEditor(); + +private: + RideEditor *rideEditor; + +}; + +// +// Dialog for finding values across the ride +// +class FindDialog : public QDialog +{ + Q_OBJECT + + public: + + FindDialog(RideEditor *, QWidget *parent=0); + ~FindDialog(); + + private slots: + void find(); + void close(); + void selection(); + void typeChanged(int); + void dataChanged(); + + private: + RideEditor *rideEditor; + + QComboBox *type; + QDoubleSpinBox *from, *to; + QLabel *andLabel; + + QList channels; + QPushButton *findButton, *closeButton; + QTableWidget *resultsTable; + + void clearResultsTable(); +}; + +// +// Dialog for paste special +// +class PasteSpecialDialog : public QDialog +{ + Q_OBJECT + + public: + + PasteSpecialDialog(RideEditor *, QWidget *parent=0); + ~PasteSpecialDialog(); + + private slots: + void okClicked(); + void cancelClicked(); + + void setColumnSelect(); + void columnChanged(); + void sepsChanged(); + + private: + RideEditor *rideEditor; + bool active; + + QStringList seps; + QVector > cells; + QStandardItemModel *model; + QStringList headings; // the header values + QStringList sourceHeadings; // source header values + + // Group boxes + QGroupBox *mode, *separators, *contents; + + // insert/overwrite + QRadioButton *append, *overwrite; + QDoubleSpinBox *atRow; + + // separator options + QCheckBox *tab, *comma, *semi, *space, *other; + QLineEdit *otherText; + QComboBox *textDelimeter; + QCheckBox *hasHeader; + + // table selection + QComboBox *columnSelect; + QTableView *resultsTable; + void setResultsTable(); + void clearResultsTable(); + + QPushButton *okButton, *cancelButton; +}; +#endif // _GC_RideEditor_h diff --git a/src/RideFile.cpp b/src/RideFile.cpp index d8ad45409..2206cdc20 100644 --- a/src/RideFile.cpp +++ b/src/RideFile.cpp @@ -18,6 +18,7 @@ */ #include "RideFile.h" +#include "DataProcessor.h" #include "Settings.h" #include "Units.h" #include @@ -31,6 +32,49 @@ interval = point->interval; \ start = point->secs; \ } + +#define tr(s) QObject::tr(s) + +RideFile::RideFile(const QDateTime &startTime, double recIntSecs) : + startTime_(startTime), recIntSecs_(recIntSecs), + deviceType_("unknown"), data(NULL) +{ + command = new RideFileCommand(this); +} + +RideFile::RideFile() : recIntSecs_(0.0), deviceType_("unknown"), data(NULL) +{ + command = new RideFileCommand(this); +} + +RideFile::~RideFile() +{ + foreach(RideFilePoint *point, dataPoints_) + delete point; + delete command; +} + +QString +RideFile::seriesName(SeriesType series) +{ + switch (series) { + case RideFile::secs: return QString(tr("Time")); + case RideFile::cad: return QString(tr("Cadence")); + case RideFile::hr: return QString(tr("Heartrate")); + case RideFile::km: return QString(tr("Distance")); + case RideFile::kph: return QString(tr("Speed")); + case RideFile::nm: return QString(tr("Torque")); + case RideFile::watts: return QString(tr("Power")); + case RideFile::alt: return QString(tr("Altitude")); + case RideFile::lon: return QString(tr("Longitude")); + case RideFile::lat: return QString(tr("Latitude")); + case RideFile::headwind: return QString(tr("Headwind")); + case RideFile::interval: return QString(tr("Interval")); + default: return QString(tr("Unknown")); + } +} + + void RideFile::clearIntervals() { @@ -206,8 +250,12 @@ RideFile *RideFileFactory::openRideFile(QFile &file, RideFileReader *reader = readFuncs_.value(suffix.toLower()); assert(reader); RideFile *result = reader->openRideFile(file, errors); - if (result && result->intervals().empty()) - result->fillInIntervals(); + + if (result->intervals().empty()) result->fillInIntervals(); + + result->setTag("Filename", file.fileName()); + DataProcessorFactory::instance().autoProcess(result); + return result; } @@ -220,8 +268,7 @@ QStringList RideFileFactory::listRideFiles(const QDir &dir) const filters << ("*." + i.key()); } // This will read the user preferences and change the file list order as necessary: - boost::shared_ptr settings = GetApplicationSettings();; - + boost::shared_ptr settings = GetApplicationSettings(); QVariant isAscending = settings->value(GC_ALLRIDES_ASCENDING,Qt::Checked); if(isAscending.toInt()>0){ return dir.entryList(filters, QDir::Files, QDir::Name); @@ -248,3 +295,191 @@ void RideFile::appendPoint(double secs, double cad, double hr, double km, dataPresent.headwind |= (headwind != 0); dataPresent.interval |= (interval != 0); } + +void +RideFile::setDataPresent(SeriesType series, bool value) +{ + switch (series) { + case secs : dataPresent.secs = value; break; + case cad : dataPresent.cad = value; break; + case hr : dataPresent.hr = value; break; + case km : dataPresent.km = value; break; + case kph : dataPresent.kph = value; break; + case nm : dataPresent.nm = value; break; + case watts : dataPresent.watts = value; break; + case alt : dataPresent.alt = value; break; + case lon : dataPresent.lon = value; break; + case lat : dataPresent.lat = value; break; + case headwind : dataPresent.headwind = value; break; + case interval : dataPresent.interval = value; break; + case none : break; + } +} + +bool +RideFile::isDataPresent(SeriesType series) +{ + switch (series) { + case secs : return dataPresent.secs; break; + case cad : return dataPresent.cad; break; + case hr : return dataPresent.hr; break; + case km : return dataPresent.km; break; + case kph : return dataPresent.kph; break; + case nm : return dataPresent.nm; break; + case watts : return dataPresent.watts; break; + case alt : return dataPresent.alt; break; + case lon : return dataPresent.lon; break; + case lat : return dataPresent.lat; break; + case headwind : return dataPresent.headwind; break; + case interval : return dataPresent.interval; break; + case none : break; + } + return false; +} +void +RideFile::setPointValue(int index, SeriesType series, double value) +{ + switch (series) { + case secs : dataPoints_[index]->secs = value; break; + case cad : dataPoints_[index]->cad = value; break; + case hr : dataPoints_[index]->hr = value; break; + case km : dataPoints_[index]->km = value; break; + case kph : dataPoints_[index]->kph = value; break; + case nm : dataPoints_[index]->nm = value; break; + case watts : dataPoints_[index]->watts = value; break; + case alt : dataPoints_[index]->alt = value; break; + case lon : dataPoints_[index]->lon = value; break; + case lat : dataPoints_[index]->lat = value; break; + case headwind : dataPoints_[index]->headwind = value; break; + case interval : dataPoints_[index]->interval = value; break; + case none : break; + } +} + +double +RideFile::getPointValue(int index, SeriesType series) +{ + switch (series) { + case secs : return dataPoints_[index]->secs; break; + case cad : return dataPoints_[index]->cad; break; + case hr : return dataPoints_[index]->hr; break; + case km : return dataPoints_[index]->km; break; + case kph : return dataPoints_[index]->kph; break; + case nm : return dataPoints_[index]->nm; break; + case watts : return dataPoints_[index]->watts; break; + case alt : return dataPoints_[index]->alt; break; + case lon : return dataPoints_[index]->lon; break; + case lat : return dataPoints_[index]->lat; break; + case headwind : return dataPoints_[index]->headwind; break; + case interval : return dataPoints_[index]->interval; break; + case none : break; + } + return 0.0; // shutup the compiler +} + +int +RideFile::decimalsFor(SeriesType series) +{ + switch (series) { + case secs : return 3; break; + case cad : return 0; break; + case hr : return 0; break; + case km : return 6; break; + case kph : return 4; break; + case nm : return 2; break; + case watts : return 0; break; + case alt : return 3; break; + case lon : return 6; break; + case lat : return 6; break; + case headwind : return 4; break; + case interval : return 0; break; + case none : break; + } + return 2; // default +} + +double +RideFile::maximumFor(SeriesType series) +{ + switch (series) { + case secs : return 999999; break; + case cad : return 300; break; + case hr : return 300; break; + case km : return 999999; break; + case kph : return 999; break; + case nm : return 999; break; + case watts : return 4000; break; + case alt : return 8850; break; // mt everest is highest point above sea level + case lon : return 180; break; + case lat : return 90; break; + case headwind : return 999; break; + case interval : return 999; break; + case none : break; + } + return 9999; // default +} + +double +RideFile::minimumFor(SeriesType series) +{ + switch (series) { + case secs : return 0; break; + case cad : return 0; break; + case hr : return 0; break; + case km : return 0; break; + case kph : return 0; break; + case nm : return 0; break; + case watts : return 0; break; + case alt : return -413; break; // the Red Sea is lowest land point on earth + case lon : return -180; break; + case lat : return -90; break; + case headwind : return -999; break; + case interval : return 0; break; + case none : break; + } + return 0; // default +} + +void +RideFile::deletePoint(int index) +{ + delete dataPoints_[index]; + dataPoints_.remove(index); +} + +void +RideFile::deletePoints(int index, int count) +{ + for(int i=index; i<(index+count); i++) delete dataPoints_[i]; + dataPoints_.remove(index, count); +} + +void +RideFile::insertPoint(int index, RideFilePoint *point) +{ + dataPoints_.insert(index, point); +} + +void +RideFile::appendPoints(QVector newRows) +{ + dataPoints_ += newRows; +} + +void +RideFile::emitSaved() +{ + emit saved(); +} + +void +RideFile::emitReverted() +{ + emit reverted(); +} + +void +RideFile::emitModified() +{ + emit modified(); +} diff --git a/src/RideFile.h b/src/RideFile.h index 2c9ec6299..086fb31c9 100644 --- a/src/RideFile.h +++ b/src/RideFile.h @@ -25,6 +25,11 @@ #include #include #include +#include + +class RideItem; +class EditorData; // attached to a RideFile +class RideFileCommand; // for manipulating ride data // This file defines four classes: // @@ -71,43 +76,50 @@ struct RideFileInterval start(start), stop(stop), name(name) {} }; -class RideFile +class RideFile : public QObject // QObject to emit signals { - private: - - QDateTime startTime_; // time of day that the ride started - double recIntSecs_; // recording interval in seconds - QVector dataPoints_; - RideFileDataPresent dataPresent; - QString deviceType_; - QList intervals_; + Q_OBJECT public: - RideFile() : recIntSecs_(0.0), deviceType_("unknown") {} - RideFile(const QDateTime &startTime, double recIntSecs) : - startTime_(startTime), recIntSecs_(recIntSecs), - deviceType_("unknown") {} + friend class RideFileCommand; // tells us we were modified + friend class MainWindow; // tells us we were saved - virtual ~RideFile() { - foreach(RideFilePoint *point, dataPoints_) - delete point; - } + // Constructor / Destructor + RideFile(); + RideFile(const QDateTime &startTime, double recIntSecs); + virtual ~RideFile(); - const QDateTime &startTime() const { return startTime_; } - double recIntSecs() const { return recIntSecs_; } - const QVector dataPoints() const { return dataPoints_; } - inline const RideFileDataPresent *areDataPresent() const { return &dataPresent; } - const QString &deviceType() const { return deviceType_; } - - void setStartTime(const QDateTime &value) { startTime_ = value; } - void setRecIntSecs(double value) { recIntSecs_ = value; } - void setDeviceType(const QString &value) { deviceType_ = value; } + // Working with DATASERIES + enum seriestype { secs, cad, hr, km, kph, nm, watts, alt, lon, lat, headwind, interval, none }; + typedef enum seriestype SeriesType; + static QString seriesName(SeriesType); + static int decimalsFor(SeriesType series); + static double maximumFor(SeriesType series); + static double minimumFor(SeriesType series); + // Working with DATAPOINTS -- ***use command to modify*** + RideFileCommand *command; + double getPointValue(int index, SeriesType series); void appendPoint(double secs, double cad, double hr, double km, double kph, double nm, double watts, double alt, double lon, double lat, double headwind, int interval); + const QVector &dataPoints() const { return dataPoints_; } + // Working with DATAPRESENT flags + inline const RideFileDataPresent *areDataPresent() const { return &dataPresent; } + void resetDataPresent(); + bool isDataPresent(SeriesType series); + + // Working with FIRST CLASS variables + const QDateTime &startTime() const { return startTime_; } + void setStartTime(const QDateTime &value) { startTime_ = value; } + double recIntSecs() const { return recIntSecs_; } + void setRecIntSecs(double value) { recIntSecs_ = value; } + const QString &deviceType() const { return deviceType_; } + void setDeviceType(const QString &value) { deviceType_ = value; } + + // Working with INTERVALS const QList &intervals() const { return intervals_; } void addInterval(double start, double stop, const QString &name) { intervals_.append(RideFileInterval(start, stop, name)); @@ -118,18 +130,60 @@ class RideFile void writeAsCsv(QFile &file, bool bIsMetric) const; - void resetDataPresent(); - + // Index offset calculations double timeToDistance(double) const; // get distance in km at time in secs int timeIndex(double) const; // get index offset for time in secs int distanceIndex(double) const; // get index offset for distance in KM + // Working with the METADATA TAGS const QMap& tags() const { return tags_; } - QString getTag(QString name, QString fallback) { return tags_.value(name, fallback); } + QString getTag(QString name, QString fallback) const { return tags_.value(name, fallback); } void setTag(QString name, QString value) { tags_.insert(name, value); } + // METRIC OVERRIDES QMap > metricOverrides; + + // editor data is held here and updated + // as rows/columns are added/removed + // this is a workaround to avoid holding + // state data within each RideFilePoint structure + // and avoiding impact on existing code + EditorData *editorData() { return data; } + void setEditorData(EditorData *x) { data = x; } + + // ************************ WARNING *************************** + // you shouldn't use these routines directly + // rather use the RideFileCommand *command + // to manipulate the ride data + void setPointValue(int index, SeriesType series, double value); + void deletePoint(int index); + void deletePoints(int index, int count); + void insertPoint(int index, RideFilePoint *point); + void appendPoints(QVector newRows); + void setDataPresent(SeriesType, bool); + // ************************************************************ + + signals: + void saved(); + void reverted(); + void modified(); + + protected: + void emitSaved(); + void emitReverted(); + void emitModified(); + + private: + + QDateTime startTime_; // time of day that the ride started + double recIntSecs_; // recording interval in seconds + QVector dataPoints_; + RideFileDataPresent dataPresent; + QString deviceType_; + QList intervals_; QMap tags_; + EditorData *data; + }; struct RideFileReader { @@ -163,4 +217,3 @@ class RideFileFactory { }; #endif // _RideFile_h - diff --git a/src/RideFileCommand.cpp b/src/RideFileCommand.cpp new file mode 100644 index 000000000..29e76a2f8 --- /dev/null +++ b/src/RideFileCommand.cpp @@ -0,0 +1,410 @@ +/* + * Copyright (c) 2010 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 "RideFile.h" +#include "RideFileCommand.h" +#include "RideEditor.h" +#include +#include + +#define tr(s) QObject::tr(s) + +// comparing doubles is nasty +static bool doubles_equal(double a, double b) +{ + double errorB = b * DBL_EPSILON; + return (a >= b - errorB) && (a <= b + errorB); +} +//---------------------------------------------------------------------- +// The public interface to the commands +//---------------------------------------------------------------------- +RideFileCommand::RideFileCommand(RideFile *ride) : ride(ride), stackptr(0), inLUW(false), luw(NULL) +{ + connect(ride, SIGNAL(saved()), this, SLOT(clearHistory())); + connect(ride, SIGNAL(reverted()), this, SLOT(clearHistory())); +} + +RideFileCommand::~RideFileCommand() +{ + clearHistory(); +} + +void +RideFileCommand::setPointValue(int index, RideFile::SeriesType series, double value) +{ + SetPointValueCommand *cmd = new SetPointValueCommand(ride, index, series, + ride->getPointValue(index, series), value); + doCommand(cmd); +} + +void +RideFileCommand::deletePoint(int index) +{ + RideFilePoint current = *ride->dataPoints()[index]; + DeletePointCommand *cmd = new DeletePointCommand(ride, index, current); + doCommand(cmd); +} + +void +RideFileCommand::deletePoints(int index, int count) +{ + QVector current; + for(int i=0; i< count; i++) { + RideFilePoint point = *ride->dataPoints()[index+i]; + current.append(point); + } + DeletePointsCommand *cmd = new DeletePointsCommand(ride, index, count, current); + doCommand(cmd); +} + +void +RideFileCommand::insertPoint(int index, RideFilePoint *points) +{ + InsertPointCommand *cmd = new InsertPointCommand(ride, index, points); + doCommand(cmd); +} + +void +RideFileCommand::appendPoints(QVector newRows) +{ + AppendPointsCommand *cmd = new AppendPointsCommand(ride, ride->dataPoints().count(), newRows); + doCommand(cmd); +} + +void +RideFileCommand::setDataPresent(RideFile::SeriesType type, bool newvalue) +{ + bool oldvalue = ride->isDataPresent(type); + SetDataPresentCommand *cmd = new SetDataPresentCommand(ride, type, newvalue, oldvalue); + doCommand(cmd); +} + +QString +RideFileCommand::changeLog() +{ + QString log; + for (int i=0; itype != RideCommand::NoOp) + log += stack[i]->description + '\n'; + } + return log; +} +//---------------------------------------------------------------------- +// Manage the Command Stack +//---------------------------------------------------------------------- +void +RideFileCommand::clearHistory() +{ + foreach (RideCommand *cmd, stack) delete cmd; + stack.clear(); + stackptr = 0; +} + +void +RideFileCommand::startLUW(QString name) +{ + luw = new LUWCommand(this, name, ride); + inLUW = true; + beginCommand(false, luw); +} + +void +RideFileCommand::endLUW() +{ + if (inLUW == false) return; // huh? + inLUW = false; + + // add to the stack if it isn't empty + if (luw->worklist.count()) doCommand(luw, true); +} + +void +RideFileCommand::doCommand(RideCommand *cmd, bool noexec) +{ + + // we must add to the LUW, but also must + // execute immediately since state data + // is collected by each command as it is + // created. + if (inLUW) { + luw->addCommand(cmd); + beginCommand(false, cmd); + cmd->doCommand(); // luw must be executed as added!!! + cmd->docount++; + endCommand(false, cmd); + return; + } + + // place onto stack + if (stack.count()) { + stack.remove(stackptr, stack.count() - stackptr); + // XXX mem leak need to delete + } + stack.append(cmd); + stackptr++; + + if (noexec == false) { + beginCommand(false, cmd); // signal + cmd->doCommand(); // execute + } + cmd->docount++; + endCommand(false, cmd); // signal - even if LUW + + // we changed it! + ride->emitModified(); +} + +void +RideFileCommand::redoCommand() +{ + if (stackptr < stack.count()) { + beginCommand(false, stack[stackptr]); // signal + stack[stackptr]->doCommand(); + stack[stackptr]->docount++; + stackptr++; // increment before end to keep in sync in case + // it is queried 'after' the command is executed + // i.e. within a slot connected to this signal + endCommand(false, stack[stackptr-1]); // signal + } +} + +void +RideFileCommand::undoCommand() +{ + if (stackptr > 0) { + stackptr--; + + beginCommand(true, stack[stackptr]); // signal + stack[stackptr]->undoCommand(); + endCommand(true, stack[stackptr]); // signal + } +} + +int +RideFileCommand::undoCount() +{ + return stackptr; +} + +int +RideFileCommand::redoCount() +{ + return stack.count() - stackptr; +} + +void +RideFileCommand::emitBeginCommand(bool undo, RideCommand *cmd) +{ + emit beginCommand(undo, cmd); +} + +void +RideFileCommand::emitEndCommand(bool undo, RideCommand *cmd) +{ + emit endCommand(undo, cmd); +} + +//---------------------------------------------------------------------- +// Commands... +//---------------------------------------------------------------------- + +// Logical Unit of work +LUWCommand::LUWCommand(RideFileCommand *commander, QString name, RideFile *ride) : + RideCommand(ride), commander(commander) +{ + type = RideCommand::LUW; + description = name; +} + +LUWCommand::~LUWCommand() +{ + foreach(RideCommand *cmd, worklist) delete cmd; +} + +bool +LUWCommand::doCommand() +{ + foreach(RideCommand *cmd, worklist) { + commander->emitBeginCommand(false, cmd); + cmd->doCommand(); + commander->emitEndCommand(false, cmd); + } + return true; +} + +bool +LUWCommand::undoCommand() +{ + for (int i=worklist.count(); i > 0; i--) { + RideCommand *cmd = worklist[i-1]; + commander->emitBeginCommand(true, cmd); + cmd->undoCommand(); + commander->emitEndCommand(true, cmd); + } + return true; +} + +// Set point value +SetPointValueCommand::SetPointValueCommand(RideFile *ride, int row, + RideFile::SeriesType series, double oldvalue, double newvalue) : + RideCommand(ride), // base class looks after these + row(row), series(series), oldvalue(oldvalue), newvalue(newvalue) +{ + type = RideCommand::SetPointValue; + description = tr("Set Value"); +} + +bool +SetPointValueCommand::doCommand() +{ + // check it has changed first! + if (!doubles_equal(oldvalue, newvalue)) { + ride->setPointValue(row,series,newvalue); + } + return true; +} + +bool +SetPointValueCommand::undoCommand() +{ + if (!doubles_equal(oldvalue, newvalue)) { + ride->setPointValue(row,series,oldvalue); + } + return true; +} + +// Remove a point +DeletePointCommand::DeletePointCommand(RideFile *ride, int row, RideFilePoint point) : + RideCommand(ride), // base class looks after these + row(row), point(point) +{ + type = RideCommand::DeletePoint; + description = tr("Remove Point"); +} + +bool +DeletePointCommand::doCommand() +{ + ride->deletePoint(row); + return true; +} + +bool +DeletePointCommand::undoCommand() +{ + ride->insertPoint(row, new RideFilePoint(point)); + return true; +} + +// Remove points +DeletePointsCommand::DeletePointsCommand(RideFile *ride, int row, int count, + QVector current) : + RideCommand(ride), // base class looks after these + row(row), count(count), points(current) +{ + type = RideCommand::DeletePoints; + description = tr("Remove Points"); +} + +bool +DeletePointsCommand::doCommand() +{ + ride->deletePoints(row, count); + return true; +} + +bool +DeletePointsCommand::undoCommand() +{ + for (int i=(count-1); i>=0; i--) ride->insertPoint(row, new RideFilePoint(points[i])); + return true; +} + +// Insert a point +InsertPointCommand::InsertPointCommand(RideFile *ride, int row, RideFilePoint *point) : + RideCommand(ride), // base class looks after these + row(row), point(*point) +{ + type = RideCommand::InsertPoint; + description = tr("Insert Point"); +} + +bool +InsertPointCommand::doCommand() +{ + ride->insertPoint(row, new RideFilePoint(point)); + return true; +} + +bool +InsertPointCommand::undoCommand() +{ + ride->deletePoint(row); + return true; +} + +// Append points +AppendPointsCommand::AppendPointsCommand(RideFile *ride, int row, QVector points) : + RideCommand(ride), // base class looks after these + row(row), count (points.count()), points(points) +{ + type = RideCommand::AppendPoints; + description = tr("Append Points"); +} + +bool +AppendPointsCommand::doCommand() +{ + QVector newPoints; + foreach (RideFilePoint point, points) { + RideFilePoint *p = new RideFilePoint(point); + newPoints.append(p); + } + ride->appendPoints(newPoints); + return true; +} + +bool +AppendPointsCommand::undoCommand() +{ + for (int i=0; ideletePoint(row); + return true; +} + +SetDataPresentCommand::SetDataPresentCommand(RideFile *ride, + RideFile::SeriesType series, bool newvalue, bool oldvalue) : + RideCommand(ride), // base class looks after these + series(series), oldvalue(oldvalue), newvalue(newvalue) +{ + type = RideCommand::SetDataPresent; + description = tr("Set Data Present"); +} + +bool +SetDataPresentCommand::doCommand() +{ + ride->setDataPresent(series, newvalue); + return true; +} + +bool +SetDataPresentCommand::undoCommand() +{ + ride->setDataPresent(series, oldvalue); + return true; +} diff --git a/src/RideFileCommand.h b/src/RideFileCommand.h new file mode 100644 index 000000000..6a46add19 --- /dev/null +++ b/src/RideFileCommand.h @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2010 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 + */ + +#ifndef _RideFileCommand_h +#define _RideFileCommand_h + +#include +#include +#include +#include +#include +#include +#include + +#include "RideFile.h" + +// for modifying ride data - implements the command pattern +// for undo/redo functionality +class RideCommand; +class LUWCommand; + +class RideFileCommand : public QObject +{ + Q_OBJECT + + friend class LUWCommand; + + public: + RideFileCommand(RideFile *ride); + ~RideFileCommand(); + + void setPointValue(int index, RideFile::SeriesType series, double value); + void deletePoint(int index); + void deletePoints(int index, int count); + void insertPoint(int index, RideFilePoint *point); + void appendPoints(QVector newRows); + void setDataPresent(RideFile::SeriesType, bool); + + // execute atomic actions + void doCommand(RideCommand*, bool noexec=false); + void undoCommand(); + void redoCommand(); + + // stack status + void startLUW(QString name); + void endLUW(); + + // change status + QString changeLog(); + int undoCount(); + int redoCount(); + + public slots: + void clearHistory(); + + signals: + void beginCommand(bool undo, RideCommand *cmd); + void endCommand(bool undo, RideCommand *cmd); + + protected: + void emitBeginCommand(bool, RideCommand *cmd); + void emitEndCommand(bool, RideCommand *cmd); + + private: + RideFile *ride; + QVector stack; + int stackptr; + bool inLUW; + LUWCommand *luw; +}; + +// The Command itself, as a base class with +// subclasses for each type +class RideCommand +{ + public: + // supported command types + enum commandtype { NoOp, LUW, SetPointValue, DeletePoint, DeletePoints, InsertPoint, AppendPoints, SetDataPresent }; + typedef enum commandtype CommandType; + + RideCommand(RideFile *ride) : type(NoOp), ride(ride), docount(0) {} + virtual bool doCommand() { return true; } + virtual bool undoCommand() { return true; } + + // state of selection model -- if passed at all + CommandType type; + QString description; + RideFile *ride; + int docount; // how many times has this been executed? +}; + +//---------------------------------------------------------------------- +// The commands +//---------------------------------------------------------------------- +class LUWCommand : public RideCommand +{ + public: + LUWCommand(RideFileCommand *command, QString name, RideFile *ride); + ~LUWCommand(); // needs to clear worklist entries + + void addCommand(RideCommand *cmd) { worklist.append(cmd); } + bool doCommand(); + bool undoCommand(); + + QVector worklist; + RideFileCommand *commander; +}; + +class SetPointValueCommand : public RideCommand +{ + public: + SetPointValueCommand(RideFile *ride, int row, RideFile::SeriesType series, double oldvalue, double newvalue); + bool doCommand(); + bool undoCommand(); + + // state + int row; + RideFile::SeriesType series; + double oldvalue, newvalue; +}; + +class DeletePointCommand : public RideCommand +{ + public: + DeletePointCommand(RideFile *ride, int row, RideFilePoint point); + bool doCommand(); + bool undoCommand(); + + // state + int row; + RideFilePoint point; +}; + +class DeletePointsCommand : public RideCommand +{ + public: + DeletePointsCommand(RideFile *ride, int row, int count, + QVector current); + bool doCommand(); + bool undoCommand(); + + // state + int row; + int count; + QVector points; +}; + +class InsertPointCommand : public RideCommand +{ + public: + InsertPointCommand(RideFile *ride, int row, RideFilePoint *point); + bool doCommand(); + bool undoCommand(); + + // state + int row; + RideFilePoint point; +}; +class AppendPointsCommand : public RideCommand +{ + public: + AppendPointsCommand(RideFile *ride, int row, QVector points); + bool doCommand(); + bool undoCommand(); + + int row, count; + QVector points; +}; +class SetDataPresentCommand : public RideCommand +{ + public: + SetDataPresentCommand(RideFile *ride, RideFile::SeriesType series, + bool newvalue, bool oldvalue); + bool doCommand(); + bool undoCommand(); + RideFile::SeriesType series; + bool oldvalue, newvalue; +}; +#endif // _RideFileCommand_h diff --git a/src/RideFileTableModel.cpp b/src/RideFileTableModel.cpp new file mode 100644 index 000000000..714bb01f7 --- /dev/null +++ b/src/RideFileTableModel.cpp @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2010 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 "RideFileTableModel.h" + +RideFileTableModel::RideFileTableModel(RideFile *ride) : ride(ride) +{ + setRide(ride); +} + +void +RideFileTableModel::setRide(RideFile *newride) +{ + // QPointer helps us check if the current ride has been deleted before trying to disconnect + static QPointer connection = NULL; + if (connection) { + disconnect(connection, SIGNAL(beginCommand(bool,RideCommand*)), this, SLOT(beginCommand(bool,RideCommand*))); + disconnect(connection, SIGNAL(endCommand(bool,RideCommand*)), this, SLOT(endCommand(bool,RideCommand*))); + } + + ride = newride; + tooltips.clear(); // remove the tooltips -- rideEditor will set them (this is fugly, but efficient) + + if (ride) { + + // set the headings to reflect the data that is present + setHeadings(); + + // Trap commands + connection = ride->command; + connect(ride->command, SIGNAL(beginCommand(bool,RideCommand*)), this, SLOT(beginCommand(bool,RideCommand*))); + connect(ride->command, SIGNAL(endCommand(bool,RideCommand*)), this, SLOT(endCommand(bool,RideCommand*))); + + // refresh + emit layoutChanged(); + } +} + +void +RideFileTableModel::setHeadings(RideFile::SeriesType series) +{ + headings_.clear(); + headingsType.clear(); + + // set the headings array + if (series == RideFile::secs || ride->areDataPresent()->secs) { + headings_ << tr("Time"); + headingsType << RideFile::secs; + } + if (series == RideFile::km || ride->areDataPresent()->km) { + headings_ << tr("Distance"); + headingsType << RideFile::km; + } + if (series == RideFile::watts || ride->areDataPresent()->watts) { + headings_ << tr("Power"); + headingsType << RideFile::watts; + } + if (series == RideFile::nm || ride->areDataPresent()->nm) { + headings_ << tr("Torque"); + headingsType << RideFile::nm; + } + if (series == RideFile::cad || ride->areDataPresent()->cad) { + headings_ << tr("Cadence"); + headingsType << RideFile::cad; + } + if (series == RideFile::hr || ride->areDataPresent()->hr) { + headings_ << tr("Heartrate"); + headingsType << RideFile::hr; + } + if (series == RideFile::kph || ride->areDataPresent()->kph) { + headings_ << tr("Speed"); + headingsType << RideFile::kph; + } + if (series == RideFile::alt || ride->areDataPresent()->alt) { + headings_ << tr("Altitude"); + headingsType << RideFile::alt; + } + if (series == RideFile::lat || ride->areDataPresent()->lat) { + headings_ << tr("Latitude"); + headingsType << RideFile::lat; + } + if (series == RideFile::lon || ride->areDataPresent()->lon) { + headings_ << tr("Longitude"); + headingsType << RideFile::lon; + } + if (series == RideFile::headwind || ride->areDataPresent()->headwind) { + headings_ << tr("Headwind"); + headingsType << RideFile::headwind; + } + if (series == RideFile::interval || ride->areDataPresent()->interval) { + headings_ << tr("Interval"); + headingsType << RideFile::interval; + } + +} + +Qt::ItemFlags +RideFileTableModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + return Qt::ItemIsEditable; + else + return QAbstractTableModel::flags(index) | Qt::ItemIsEditable | + Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +QVariant +RideFileTableModel::data(const QModelIndex & index, int role) const +{ + if (role == Qt::ToolTipRole) return toolTip(index.row(), columnType(index.column())); + + if (index.row() >= ride->dataPoints().count() || index.column() >= headings_.count()) + return QVariant(); + else + return ride->getPointValue(index.row(), headingsType[index.column()]); +} + +QVariant +RideFileTableModel::headerData(int section, Qt::Orientation orient, int role) const +{ + if (role != Qt::DisplayRole) return QVariant(); + + if (orient == Qt::Horizontal) { + if (section >= headings_.count()) + return QVariant(); + else { + return headings_[section]; + } + } else { + return QString("%1").arg(section+1); + } +} + +int +RideFileTableModel::rowCount(const QModelIndex &) const +{ + if (ride) return ride->dataPoints().count(); + else return 0; +} + +int +RideFileTableModel::columnCount(const QModelIndex &) const +{ + if (ride) return headingsType.count(); + else return 0; +} + +bool +RideFileTableModel::setData(const QModelIndex & index, const QVariant &value, int role) +{ + if (index.row() >= ride->dataPoints().count() || index.column() >= headings_.count()) + return false; + else if (role == Qt::EditRole) { + ride->command->setPointValue(index.row(), headingsType[index.column()], value.toDouble()); + return true; + } else return false; +} + +bool +RideFileTableModel::setHeaderData(int section, Qt::Orientation , const QVariant & value, int) +{ + if (section >= headings_.count()) return false; + else { + headings_[section] = value.toString(); + return true; + } +} + +bool +RideFileTableModel::insertRow(int row, const QModelIndex &parent) +{ + return insertRows(row, 1, parent); +} + +bool +RideFileTableModel::insertRows(int row, int count, const QModelIndex &) +{ + if (row >= ride->dataPoints().count()) return false; + else { + while (count--) { + struct RideFilePoint *p = new RideFilePoint; + ride->command->insertPoint(row, p); + } + return true; + } +} + +bool +RideFileTableModel::appendRows(QVectornewRows) +{ + ride->command->appendPoints(newRows); + return true; +} + +bool +RideFileTableModel::removeRows(int row, int count, const QModelIndex &) +{ + if ((row + count) > ride->dataPoints().count()) return false; + ride->command->deletePoints(row, count); + return true; +} + +bool +RideFileTableModel::insertColumn(RideFile::SeriesType series) +{ + if (headingsType.contains(series)) return false; // already there + + ride->command->setDataPresent(series, true); + return true; +} + +bool +RideFileTableModel::insertColumns(int, int, const QModelIndex &) +{ + // WE DON'T SUPPORT THIS + // use insertColumn(RideFile::SeriesType) instead. + return false; +} + +bool +RideFileTableModel::removeColumn(RideFile::SeriesType series) +{ + if (headingsType.contains(series)) { + ride->command->setDataPresent(series, false); + return true; + } else + return false; // its not there +} + +bool +RideFileTableModel::removeColumns (int , int , const QModelIndex &) +{ + // WE DON'T SUPPORT THIS + // use removeColumn(RideFile::SeriesType) instead. + return false; +} + +void +RideFileTableModel::setValue(int row, int column, double value) +{ + ride->command->setPointValue(row, headingsType[column], value); +} + +double +RideFileTableModel::getValue(int row, int column) +{ + return ride->getPointValue(row, headingsType[column]); +} + +void +RideFileTableModel::forceRedraw() +{ + // tell the view to redraw everything + dataChanged(createIndex(0,0), createIndex(headingsType.count(), ride->dataPoints().count())); +} + +// +// RideCommand has made changes... +// +void +RideFileTableModel::beginCommand(bool undo, RideCommand *cmd) +{ + switch (cmd->type) { + + case RideCommand::SetPointValue: + break; + + case RideCommand::InsertPoint: + { + InsertPointCommand *dp = (InsertPointCommand *)cmd; + if (!undo) beginInsertRows(QModelIndex(), dp->row, dp->row); + else beginRemoveRows(QModelIndex(), dp->row, dp->row); + break; + } + + case RideCommand::DeletePoint: + { + DeletePointCommand *dp = (DeletePointCommand *)cmd; + if (undo) beginInsertRows(QModelIndex(), dp->row, dp->row); + else beginRemoveRows(QModelIndex(), dp->row, dp->row); + break; + } + + case RideCommand::DeletePoints: + { + DeletePointsCommand *ds = (DeletePointsCommand *)cmd; + if (undo) beginInsertRows(QModelIndex(), ds->row, ds->row + ds->count - 1); + else beginRemoveRows(QModelIndex(), ds->row, ds->row + ds->count - 1); + break; + } + + case RideCommand::AppendPoints: + { + AppendPointsCommand *ap = (AppendPointsCommand *)cmd; + if (!undo) beginInsertRows(QModelIndex(), ap->row, ap->row + ap->count - 1); + else beginRemoveRows(QModelIndex(), ap->row, ap->row + ap->count - 1); + break; + } + default: + break; + } +} + +void +RideFileTableModel::endCommand(bool undo, RideCommand *cmd) +{ + switch (cmd->type) { + + case RideCommand::SetPointValue: + { + SetPointValueCommand *spv = (SetPointValueCommand*)cmd; + QModelIndex cell(index(spv->row,headingsType.indexOf(spv->series))); + dataChanged(cell, cell); + break; + } + case RideCommand::InsertPoint: + if (!undo) endInsertRows(); + else endRemoveRows(); + break; + + case RideCommand::DeletePoint: + case RideCommand::DeletePoints: + if (undo) endInsertRows(); + else endRemoveRows(); + break; + + case RideCommand::AppendPoints: + if (undo) endRemoveRows(); + else endInsertRows(); + break; + + case RideCommand::SetDataPresent: + setHeadings(); + emit layoutChanged(); + break; + + default: + break; + } +} + +// Tooltips are kept in a QMap, since they SHOULD be sparse +static QString xsstring(int x, RideFile::SeriesType series) +{ + return QString("%1:%2").arg((int)x).arg(static_cast(series)); +} + +void +RideFileTableModel::setToolTip(int row, RideFile::SeriesType series, QString text) +{ + QString key = xsstring(row, series); + + // if text is blank we are removing it + if (text == "") tooltips.remove(key); + + // if text is non-blank we are changing it + if (text != "") tooltips.insert(key, text); +} + +QString +RideFileTableModel::toolTip(int row, RideFile::SeriesType series) const +{ + QString key = xsstring(row, series); + return tooltips.value(key, ""); +} diff --git a/src/RideFileTableModel.h b/src/RideFileTableModel.h new file mode 100644 index 000000000..5695e40c6 --- /dev/null +++ b/src/RideFileTableModel.h @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2010 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 + */ + +#ifndef _GC_RideFileTableModel_h +#define _GC_RideFileTableModel_h 1 + +#include "RideFile.h" +#include "RideFileCommand.h" +#include "MainWindow.h" +#include + +// +// Provides a QAbstractTableModel interface to a ridefile and can be used as a +// model in a QTableView to view RideFile datapoints. Used by the RideEditor +// +// All modifications are made via the RideFileCommand interface to ensure the +// command pattern is honored for undo/redo etc. +// +class RideFileTableModel : public QAbstractTableModel +{ + Q_OBJECT + + public: + + RideFileTableModel(RideFile *ride = 0); + + // when we choose new ride + void setRide(RideFile *ride); + + // used by the view - mandatory implementation + Qt::ItemFlags flags(const QModelIndex &index) const; + QVariant data(const QModelIndex & index, int role = Qt::DisplayRole) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex & parent = QModelIndex()) const; + bool setData(const QModelIndex & index, const QVariant &value, int role = Qt::EditRole); + bool setHeaderData(int section, Qt::Orientation orientation, const QVariant & value, int role = Qt::EditRole); + bool insertRow(int row, const QModelIndex & parent = QModelIndex()); + bool insertRows(int row, int count, const QModelIndex & parent = QModelIndex()); + bool removeRows(int row, int count, const QModelIndex & parent = QModelIndex()); + bool appendRows(QVectornewRows); + + // DO NOT USE THESE -- THEY ARE NULL -- + bool insertColumns (int column, int count, const QModelIndex & parent = QModelIndex()); + bool removeColumns (int column, int count, const QModelIndex & parent = QModelIndex()); + // USE THESE INSTEAD + bool insertColumn(RideFile::SeriesType series); + bool removeColumn(RideFile::SeriesType series); + + // get/set value by column number + void setValue(int row, int column, double value); + double getValue(int row, int column); + + // get/interpret headings + QStringList &headings() { return headings_; } + RideFile::SeriesType columnType(int index) const { return headingsType[index]; } + int columnFor(RideFile::SeriesType series) const { return headingsType.indexOf(series); } + + // get/set tooltip + void setToolTip(int row, RideFile::SeriesType series, QString value); + QString toolTip(int row, RideFile::SeriesType series) const; + + public slots: + // RideCommand signals trapped here + void beginCommand(bool undo, RideCommand *); + void endCommand(bool undo, RideCommand *); + + // force redraw - used by anomaly detection and find + void forceRedraw(); + + protected: + + private: + RideFile *ride; + QMap tooltips; + + QStringList headings_; + QVector headingsType; + void setHeadings(RideFile::SeriesType series = RideFile::none); +}; +#endif // _GC_RideFileTableModel_h diff --git a/src/RideItem.cpp b/src/RideItem.cpp index 4a7584517..d311c3597 100644 --- a/src/RideItem.cpp +++ b/src/RideItem.cpp @@ -16,6 +16,7 @@ * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +#include #include "RideItem.h" #include "RideMetric.h" #include "RideFile.h" @@ -27,7 +28,7 @@ RideItem::RideItem(int type, QString path, QString fileName, const QDateTime &dateTime, const Zones *zones, QString notesFileName, MainWindow *main) : - QTreeWidgetItem(type), ride_(NULL), main(main), isdirty(false), path(path), fileName(fileName), + QTreeWidgetItem(type), ride_(NULL), main(main), isdirty(false), isedit(false), path(path), fileName(fileName), dateTime(dateTime), zones(zones), notesFileName(notesFileName) { setText(0, dateTime.toString("ddd")); @@ -44,11 +45,43 @@ RideFile *RideItem::ride() // open the ride file QFile file(path + "/" + fileName); ride_ = RideFileFactory::instance().openRideFile(file, errors_); + setDirty(false); // we're gonna use on-disk so by + // definition it is clean - but do it *after* + // we read the file since it will almost + // certainly be referenced by consuming widgets + + // stay aware of state changes to our ride + // MainWindow saves and RideFileCommand modifies + connect(ride_, SIGNAL(modified()), this, SLOT(modified())); + connect(ride_, SIGNAL(saved()), this, SLOT(saved())); + connect(ride_, SIGNAL(reverted()), this, SLOT(reverted())); + return ride_; } + +void +RideItem::modified() +{ + setDirty(true); +} + +void +RideItem::saved() +{ + setDirty(false); +} + +void +RideItem::reverted() +{ + setDirty(false); +} + void RideItem::setDirty(bool val) { + if (isdirty == val) return; // np change + isdirty = val; if (isdirty == true) { @@ -60,6 +93,8 @@ RideItem::setDirty(bool val) setFont(i, current); } + main->notifyRideDirty(); + } else { // show ride in normal on the list view @@ -68,6 +103,8 @@ RideItem::setDirty(bool val) current.setWeight(QFont::Normal); setFont(i, current); } + + main->notifyRideClean(); } } diff --git a/src/RideItem.h b/src/RideItem.h index 578ce3157..b84b94e92 100644 --- a/src/RideItem.h +++ b/src/RideItem.h @@ -20,13 +20,24 @@ #define _GC_RideItem_h 1 #include +#include #include "RideMetric.h" class RideFile; +class RideEditor; class MainWindow; class Zones; -class RideItem : public QTreeWidgetItem { +// Because we have subclassed QTreeWidgetItem we +// need to use our own type, this MUST be greater than +// QTreeWidgetItem::UserType according to the docs +#define FOLDER_TYPE 0 +#define RIDE_TYPE (QTreeWidgetItem::UserType+1) + +class RideItem : public QObject, public QTreeWidgetItem //<< for signals/slots +{ + + Q_OBJECT protected: @@ -36,12 +47,19 @@ class RideItem : public QTreeWidgetItem { MainWindow *main; // to notify widgets when date/time changes bool isdirty; + public slots: + void modified(); + void reverted(); + void saved(); + public: + bool isedit; // is being edited at the moment + QString path; QString fileName; QDateTime dateTime; - QDateTime computeMetricsTime; + QDateTime computeMetricsTime; RideFile *ride(); const QStringList errors() { return errors_; } const Zones *zones; @@ -65,4 +83,3 @@ class RideItem : public QTreeWidgetItem { double timeInZone(int zone); }; #endif // _GC_RideItem_h - diff --git a/src/RideMetadata.cpp b/src/RideMetadata.cpp index 8a0b7d8f5..1ced36b78 100644 --- a/src/RideMetadata.cpp +++ b/src/RideMetadata.cpp @@ -221,7 +221,13 @@ FormField::FormField(FieldDefinition field, MainWindow *main) : definition(field widget = main->rideNotesWidget(); } else { widget = new QTextEdit(this); - connect (widget, SIGNAL(textChanged()), this, SLOT(editFinished())); + if (field.name == "Change History") { + dynamic_cast(widget)->setReadOnly(true); + // pick up when ride saved - since it gets updated then + connect (main, SIGNAL(rideClean()), this, SLOT(rideSelected())); + } else { + connect (widget, SIGNAL(textChanged()), this, SLOT(editFinished())); + } } break; diff --git a/src/SaveDialogs.cpp b/src/SaveDialogs.cpp index 231cd4b31..79c1a2aa2 100644 --- a/src/SaveDialogs.cpp +++ b/src/SaveDialogs.cpp @@ -19,6 +19,7 @@ #include "GcRideFile.h" #include "RideItem.h" #include "RideFile.h" +#include "RideFileCommand.h" #include "Settings.h" #include "SaveDialogs.h" @@ -175,6 +176,13 @@ MainWindow::saveSilent(RideItem *rideItem) savedFile.setFileName(currentFile.fileName()); } + // update the change history + QString log = rideItem->ride()->getTag("Change History", ""); + log += tr("Changes on "); + log += QDateTime::currentDateTime().toString() + ":"; + log += '\n' + rideItem->ride()->command->changeLog(); + rideItem->ride()->setTag("Change History", log); + // save in GC format GcFileReader reader; reader.writeRideFile(rideItem->ride(), savedFile); @@ -190,7 +198,7 @@ MainWindow::saveSilent(RideItem *rideItem) } // mark clean as we have now saved the data - rideItem->setDirty(false); + rideItem->ride()->emitSaved(); } //---------------------------------------------------------------------- diff --git a/src/Settings.h b/src/Settings.h index ff71ae217..8f65bd28e 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -73,6 +73,13 @@ #define GC_DEV_DEFI "devicedefi" #define GC_DEV_DEFR "devicedefr" +// data processor config +#define GC_DPFG_TOLERANCE "dataprocess/fixgaps/tolerance" +#define GC_DPFG_STOP "dataprocess/fixgaps/stop" +#define GC_DPFS_MAX "dataprocess/fixspikes/max" +#define GC_DPFS_VARIANCE "dataprocess/fixspikes/variance" +#define GC_DPTA "dataprocess/torqueadjust/adjustment" + #include #include diff --git a/src/SpecialFields.cpp b/src/SpecialFields.cpp index ce09def4d..3cc74813f 100644 --- a/src/SpecialFields.cpp +++ b/src/SpecialFields.cpp @@ -34,6 +34,13 @@ SpecialFields::SpecialFields() << "Weight" // in WKO and possibly others << "Device" // RideFile::devicetype << "Device Info" // in WKO and TCX and possibly others + << "Dropouts" // calculated from source data by FixGaps + << "Dropout Time" // calculated from source data vy FixGaps + << "Spikes" // calculated from source data by FixSpikes + << "Spike Time" // calculated from source data by FixSpikes + << "Torque Adjust" // the last torque adjust applied + << "Filename" // set by the rideFile reader + << "Change History" // set by RideFileCommand ; // now add all the metric fields (for metric overrides) diff --git a/src/WeeklySummaryWindow.cpp b/src/WeeklySummaryWindow.cpp index 5ba1deaee..b3c161606 100644 --- a/src/WeeklySummaryWindow.cpp +++ b/src/WeeklySummaryWindow.cpp @@ -28,10 +28,6 @@ #include #include -// XXX: these are also defined in MainWindow.cpp. Fugly. -#define FOLDER_TYPE 0 -#define RIDE_TYPE 1 - WeeklySummaryWindow::WeeklySummaryWindow(bool useMetricUnits, MainWindow *mainWindow) : QWidget(mainWindow), mainWindow(mainWindow), diff --git a/src/application.qrc b/src/application.qrc index ffc0f6465..7448f987a 100644 --- a/src/application.qrc +++ b/src/application.qrc @@ -12,5 +12,14 @@ translations/gc_ja.qm xml/charts.xml xml/metadata.xml + images/toolbar/close-icon.png + images/toolbar/save.png + images/toolbar/search.png + images/toolbar/splash green.png + images/toolbar/cut.png + images/toolbar/copy.png + images/toolbar/paste.png + images/toolbar/undo.png + images/toolbar/redo.png diff --git a/src/images/toolbar/close-icon.png b/src/images/toolbar/close-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c49943a716d804793ae8262a98515da442e4bf18 GIT binary patch literal 1979 zcmV;s2SoUZP)1xYGN1xrn!SU%d)VWzjtbb9aI=e@mmxa|xA>q*|6 zxnJjb&hvhq5pC-1v!1vk(1L=8 zbY0&(yS%)rV8H@%c|7Ka0H2tJAb?Y(uaEjVJ1?n&gYO4q`5(*Aozs68fwscJxtgx; z`{SB56~3Gtx^VnBUHS1xqOmcO0O-OyE`T%2`1m*}g@sgDSxI?w=hE53hv`aBPvaAp zE;avO1U{QFWBt6!%AEzJrS$zLpHO0Wn6kWHa=Ton!38F`#0tPPsKF$Xu(3pfidU?l zS!HE(eAlj1{y?B|RZma+jtT6}&E4^EUENFPPo1Lkot@-Y6!HKRz9%h9=>7>UHJ>fYYOEeY(IIdfUr%9a2A`RGx) zaqSwpv$9OUOgl#el8a|+V27mRInU!f+eGkt^!jyLQeRIe+S*Rk^!G2kC4n!?%T-?> z5V+9QMLvL=zzho0Mn(wfMgat)#O`1PEdSdJB}YdoapMNL{eBWqj#Xen8lJ^*fAP9? zbmG8)XP>%ywIz+fhk1G1DypiQzHe(Izbu;%Sat@F4GmH0o;@@RDR`v1nu1VA0tkcK z8YJMZtf!x*@>gD=ZyOs)J9LPo+}xB)lCYeaGszzej<4$M^rR8kzi5#@)Ym7*RCQw4 zEIB^Df#_fS)mOA&`Ev6;4&_hh%%N$Jm3L`_2LKv=_uZ75pM$9O_17ejIl2`etj_(9 zK1%1l`>wHmV4yigU}rG6wy?DHi{2l8AP61Rz1Cdp@27*aXOlM=G}F-zNUjmHOG@ba`SZc21_mM~fq%`P ze<^bL@+`jK>LxiN-hfN|RaI2Ge}5W?3WBoRop%3PG)k=n1(c0v8UmKA3^7R{kf%ev z*yYRB4MRiwO#&a^d#@3NA6*XLyKE9H9sDh?T9uJRM)3T6k{K9kYIcpVNH76q`gBS} zB2CW?4{sN5DN1%gQKE79lB?a+JeRCuGY%5jw;?fv>}bXAOmG-W&ncKv0BdO=;6aw@ z@%WAxB9RT^O+^WU;WcN40kK5dms-Rg# z3KZXKj7DD)+Z9E^9nIzNyA3K?#fH^jH_X|ziK^du=aveLu{#lXg6jlQ)NE1~xZ#Kc zZKAYq-x!NEnd`ryC`kajGwq(QHTd;DfQNqr+#X3-3rH9!Uc`06YEDj9Fe#4^^_ya` zT_%BTvix5(oXWI1&s7Y~q-f(ts?Lz-16QulJ*aM(lwcAXikNT&qTwLm6{2j@$jE7v zz;;<~LfzixngWgnPct5Tkm@=*(m;km?4KyLAr8#;_O$2GWRhCYPo(VZ6gW#y6La#f zBO_V-o=M;>Sq`DF^rwQ3AF%YOgnwAQn%3>zJ8AI*95^bqG5hrP{* z7DPO+X}iEGO1u9yI$D<^z~ePp{tw`n3hSI_tDvC}J+yNtEnUAp1^yAh`8^kQXa*W$ zBp)Pd9VA*Fcz}japC&>tFdQymBJh9FtE$>Ni2&SCf&l7HwbzMMnMfm$sMx%j^0DaQ zC!Zt(@-nzRIIXx{hj0vA^zzGe1RZe*EuA~232ddL=>nW_x2kH`{&v!N04i{wHj~^* zgkRFp$;|k`qAr&!1?(I;>O40biO&+O4ohyY#RnTqDBL+ssv65kz#`FusVKF(o7*&B zmK^sa$BD>1mN~w$L7gC&AN#wi9(LlT5qMLU14yF__&I&*Gb8PW;U$&^~ zhf{B6JQZ*AdVOe}U08IUaP0D~2X|8@X&ALz z?-8hg?sF;1iY<}InOnVk+rMTXG&I~FJ$7t|+TFd<><#Ggf)AxExh1;^$T5IUXCMfL z-#?JIY}xA@4jz2}cI&4Iw6?aEV=9H^!}4G=5bhGdf|A#3BnJocqDPOGsa;(;iC=zk zk0JL{AA^*ypuq5zl*DruF1#RDRCGfD56E!;9kkMA5Eg;;!%o-K)SOBYXm4+qqS5Fw z{9J4ixCe$3Mo47JG2auyfGhk^zzb4>J{{aBGy-tHFMg;%> N002ovPDHLkV1g1!#g+g7 literal 0 HcmV?d00001 diff --git a/src/images/toolbar/copy.png b/src/images/toolbar/copy.png new file mode 100644 index 0000000000000000000000000000000000000000..9bce1ab2e0e603e9c15dfe4508c5ac92cb0c187d GIT binary patch literal 2130 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 literal 0 HcmV?d00001 diff --git a/src/images/toolbar/paste.png b/src/images/toolbar/paste.png new file mode 100644 index 0000000000000000000000000000000000000000..ed5947da782d6d47e840b590bf427a3d49bf4849 GIT binary patch literal 1844 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 i>U|l=CIBABUH@