diff --git a/plotjuggler_app/curve_tracker.cpp b/plotjuggler_app/curve_tracker.cpp index 81468e1ec..23507ae6c 100644 --- a/plotjuggler_app/curve_tracker.cpp +++ b/plotjuggler_app/curve_tracker.cpp @@ -285,13 +285,18 @@ std::optional curvePointAt(const QwtPlotCurve* curve, double x) { int index = qwtUpperSampleIndex(*curve->data(), x, compareX()); - if (index > 0) + if (index > 0 && index < curve->dataSize()) { auto p1 = (curve->sample(index - 1)); auto p2 = (curve->sample(index)); double middle_X = (p1.x() + p2.x()) / 2.0; return (x < middle_X) ? p1 : p2; } + else if (index >= curve->dataSize()) + { + // Target is at or beyond the last point - return the last point + return curve->sample(curve->dataSize() - 1); + } } return std::nullopt; } diff --git a/plotjuggler_app/mainwindow.cpp b/plotjuggler_app/mainwindow.cpp index e18481754..6aa3784d2 100644 --- a/plotjuggler_app/mainwindow.cpp +++ b/plotjuggler_app/mainwindow.cpp @@ -1881,7 +1881,7 @@ std::tuple MainWindow::calculateVisibleRangeX() const double t1 = data.back().x; min_time = std::min(min_time, t0); max_time = std::max(max_time, t1); - max_steps = std::max(max_steps, (int)data.size()); + max_steps = std::max(max_steps, (int)data.size() - 1); } } }); @@ -1898,7 +1898,7 @@ std::tuple MainWindow::calculateVisibleRangeX() const double t1 = data.back().x; min_time = std::min(min_time, t0); max_time = std::max(max_time, t1); - max_steps = std::max(max_steps, (int)data.size()); + max_steps = std::max(max_steps, (int)data.size() - 1); } } } diff --git a/plotjuggler_plugins/DataLoadCSV/dataload_csv.cpp b/plotjuggler_plugins/DataLoadCSV/dataload_csv.cpp index 94b073f52..7f767a752 100644 --- a/plotjuggler_plugins/DataLoadCSV/dataload_csv.cpp +++ b/plotjuggler_plugins/DataLoadCSV/dataload_csv.cpp @@ -215,9 +215,17 @@ DataLoadCSV::DataLoadCSV() bool box_enabled = !checked || selected.size() == 1; _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(box_enabled); }); + connect(_ui->radioButtonDateTimeColumns, &QRadioButton::toggled, this, [this](bool checked) { + _ui->listWidgetSeries->setEnabled(!checked && _ui->radioButtonSelect->isChecked()); + if (checked) + { + _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + } + }); connect(_ui->listWidgetSeries, &QListWidget::itemSelectionChanged, this, [this]() { auto selected = _ui->listWidgetSeries->selectionModel()->selectedIndexes(); - bool box_enabled = _ui->radioButtonIndex->isChecked() || selected.size() == 1; + bool box_enabled = _ui->radioButtonIndex->isChecked() || + _ui->radioButtonDateTimeColumns->isChecked() || selected.size() == 1; _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(box_enabled); }); @@ -262,6 +270,8 @@ void DataLoadCSV::parseHeader(QFile& file, std::vector& column_name column_names.clear(); _ui->listWidgetSeries->clear(); + _ui->radioButtonDateTimeColumns->setEnabled(false); + _ui->radioButtonDateTimeColumns->setText(tr("Combine Date + Time columns")); QTextStream inA(&file); // The first line should contain the header. If it contains a number, we will @@ -391,6 +401,77 @@ void DataLoadCSV::parseHeader(QFile& file, std::vector& column_name _ui->rawText->setPlainText(preview_lines); _ui->tableView->resizeColumnsToContents(); + // Auto-detect DATE_ONLY and TIME_ONLY column pairs and create combined virtual columns + _combined_columns.clear(); + + if (lines.empty()) + { + file.close(); + return; + } + + // Detect column types from the first data row + std::vector column_types(column_names.size()); + QStringList first_data_line; + SplitLine(lines[0], _delimiter, first_data_line); + + for (size_t i = 0; i < column_types.size() && i < first_data_line.size(); i++) + { + if (!first_data_line[i].isEmpty()) + { + column_types[i] = PJ::CSV::DetectColumnType(first_data_line[i].toStdString()); + } + } + + // Find DATE_ONLY and TIME_ONLY consecutive column pairs only + for (size_t i = 0; i + 1 < column_types.size(); i++) + { + const auto& type_a = column_types[i].type; + const auto& type_b = column_types[i + 1].type; + + size_t date_idx = SIZE_MAX; + size_t time_idx = SIZE_MAX; + + // Check if columns i and i+1 form a date+time pair (in either order) + if (type_a == PJ::CSV::ColumnType::DATE_ONLY && type_b == PJ::CSV::ColumnType::TIME_ONLY) + { + date_idx = i; + time_idx = i + 1; + } + else if (type_a == PJ::CSV::ColumnType::TIME_ONLY && type_b == PJ::CSV::ColumnType::DATE_ONLY) + { + date_idx = i + 1; + time_idx = i; + } + + if (date_idx == SIZE_MAX) + { + continue; + } + + // Create combined virtual column + std::string virtual_name = column_names[date_idx] + " + " + column_names[time_idx]; + + CombinedColumn combined; + combined.date_column_index = date_idx; + combined.time_column_index = time_idx; + combined.virtual_name = virtual_name; + _combined_columns.push_back(combined); + + // Skip the next column since it's already part of this pair + i++; + } + + // Enable the radio button if combined columns were detected + if (!_combined_columns.empty()) + { + _ui->radioButtonDateTimeColumns->setEnabled(true); + // Show which columns will be combined + const auto& combined = _combined_columns[0]; + _ui->radioButtonDateTimeColumns->setText( + tr("Combine Date + Time columns (%1)").arg(QString::fromStdString(combined.virtual_name))); + } + file.close(); } @@ -491,6 +572,14 @@ int DataLoadCSV::launchDialog(QFile& file, std::vector* column_name return TIME_INDEX_GENERATED; } + if (_ui->radioButtonDateTimeColumns->isChecked() && !_combined_columns.empty()) + { + // Return index pointing to the first combined column (virtual index after real columns) + settings.setValue("DataLoadCSV.timeIndex", + QString::fromStdString(_combined_columns[0].virtual_name)); + return column_names->size(); // Virtual index for combined column + } + QModelIndexList indexes = _ui->listWidgetSeries->selectionModel()->selectedRows(); if (indexes.size() == 1) { @@ -540,6 +629,7 @@ bool DataLoadCSV::readDataFromFile(FileLoadInfo* info, PlotDataMapRef& plot_data } else { + // First check regular columns for (size_t i = 0; i < column_names.size(); i++) { if (column_names[i] == _default_time_axis) @@ -548,6 +638,19 @@ bool DataLoadCSV::readDataFromFile(FileLoadInfo* info, PlotDataMapRef& plot_data break; } } + + // If not found, check virtual combined columns + if (time_index == TIME_INDEX_NOT_DEFINED) + { + for (size_t i = 0; i < _combined_columns.size(); i++) + { + if (_combined_columns[i].virtual_name == _default_time_axis) + { + time_index = column_names.size() + i; + break; + } + } + } } } @@ -671,33 +774,79 @@ bool DataLoadCSV::readDataFromFile(FileLoadInfo* info, PlotDataMapRef& plot_data if (time_index >= 0) { - t_str = string_items[time_index]; - const auto time_trimm = t_str.trimmed(); + // Check if this is a combined virtual column + bool is_combined = false; + int date_col_idx = -1; + int time_col_idx = -1; + + // Virtual columns have indices >= original column count + if (time_index >= static_cast(column_types.size())) + { + // This is a virtual combined column + int virtual_idx = time_index - column_types.size(); + if (virtual_idx < static_cast(_combined_columns.size())) + { + is_combined = true; + date_col_idx = _combined_columns[virtual_idx].date_column_index; + time_col_idx = _combined_columns[virtual_idx].time_column_index; + } + } + bool is_number = false; - if (parse_date_format) + if (is_combined && date_col_idx >= 0 && time_col_idx >= 0) { - if (auto ts = FormatParseTimestamp(time_trimm, format_string)) + // Parse combined date+time columns + const QString& date_str = string_items[date_col_idx]; + const QString& time_str = string_items[time_col_idx]; + + if (auto ts = PJ::CSV::ParseCombinedDateTime( + date_str.trimmed().toStdString(), time_str.trimmed().toStdString(), + column_types[date_col_idx], column_types[time_col_idx])) { is_number = true; timestamp = *ts; + t_str = date_str + " " + time_str; // For error messages } } else { - // Use the detected column type for the time column - const auto& time_type = column_types[time_index]; - if (time_type.type != PJ::CSV::ColumnType::STRING) + // Regular single-column timestamp parsing + t_str = string_items[time_index]; + const auto time_trimm = t_str.trimmed(); + + if (parse_date_format) { - if (auto ts = PJ::CSV::ParseWithType(time_trimm.toStdString(), time_type)) + if (auto ts = FormatParseTimestamp(time_trimm, format_string)) { is_number = true; timestamp = *ts; } } + else + { + // Use the detected column type for the time column + const auto& time_type = column_types[time_index]; + if (time_type.type != PJ::CSV::ColumnType::STRING) + { + if (auto ts = PJ::CSV::ParseWithType(time_trimm.toStdString(), time_type)) + { + is_number = true; + timestamp = *ts; + } + } + } } - time_header_str = header_string_items[time_index]; + if (is_combined) + { + time_header_str = QString::fromStdString( + _combined_columns[time_index - column_types.size()].virtual_name); + } + else + { + time_header_str = header_string_items[time_index]; + } if (!is_number) { @@ -776,6 +925,22 @@ bool DataLoadCSV::readDataFromFile(FileLoadInfo* info, PlotDataMapRef& plot_data continue; } + // Skip Date/Time columns that are part of a combined column + // (they're only used to generate the timestamp, not stored as data) + bool skip_combined_component = false; + for (const auto& combined : _combined_columns) + { + if (i == combined.date_column_index || i == combined.time_column_index) + { + skip_combined_component = true; + break; + } + } + if (skip_combined_component) + { + continue; + } + // Use the detected column type to parse the value if (col_type.type != PJ::CSV::ColumnType::STRING) { @@ -817,7 +982,21 @@ bool DataLoadCSV::readDataFromFile(FileLoadInfo* info, PlotDataMapRef& plot_data if (time_index >= 0) { - _default_time_axis = column_names[time_index]; + // Check if this is a combined virtual column + if (time_index >= static_cast(column_names.size())) + { + // Virtual combined column + int virtual_idx = time_index - column_names.size(); + if (virtual_idx < static_cast(_combined_columns.size())) + { + _default_time_axis = _combined_columns[virtual_idx].virtual_name; + } + } + else + { + // Regular column + _default_time_axis = column_names[time_index]; + } } else if (time_index == TIME_INDEX_GENERATED) { diff --git a/plotjuggler_plugins/DataLoadCSV/dataload_csv.h b/plotjuggler_plugins/DataLoadCSV/dataload_csv.h index 9ac936cf3..cbd383df8 100644 --- a/plotjuggler_plugins/DataLoadCSV/dataload_csv.h +++ b/plotjuggler_plugins/DataLoadCSV/dataload_csv.h @@ -58,4 +58,13 @@ class DataLoadCSV : public DataLoader QStandardItemModel* _model; bool multiple_columns_warning_ = true; + + // Structure to track combined date+time virtual columns + struct CombinedColumn + { + int date_column_index; + int time_column_index; + std::string virtual_name; + }; + std::vector _combined_columns; }; diff --git a/plotjuggler_plugins/DataLoadCSV/dataload_csv.ui b/plotjuggler_plugins/DataLoadCSV/dataload_csv.ui index 20fc95de6..9677927a0 100644 --- a/plotjuggler_plugins/DataLoadCSV/dataload_csv.ui +++ b/plotjuggler_plugins/DataLoadCSV/dataload_csv.ui @@ -41,6 +41,16 @@ + + + + false + + + Combine Date + Time columns + + + diff --git a/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.cpp b/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.cpp index 3e7bf2204..03651e46d 100644 --- a/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.cpp +++ b/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.cpp @@ -385,6 +385,72 @@ ColumnTypeInfo DetectColumnType(const std::string& str) auto [base_str, fractional_ns] = ExtractFractionalSeconds(trimmed); info.has_fractional = (fractional_ns.count() > 0 || trimmed != base_str); + // Check for separators first + bool has_slash = base_str.find('/') != std::string::npos; + bool has_dash = base_str.find('-') != std::string::npos; + bool has_colon = base_str.find(':') != std::string::npos; + + // Helper lambda to try parsing with multiple formats + auto tryParseFormats = [&base_str](const char* const* formats, size_t count, + auto parse_func) -> std::optional { + for (size_t i = 0; i < count; i++) + { + std::istringstream in{ base_str }; + in.imbue(std::locale::classic()); + if (parse_func(in, formats[i])) + { + return formats[i]; + } + } + return std::nullopt; + }; + + // Check for DATE_ONLY formats FIRST (date without time) + // Formats: "2024-01-15", "01/15/2024", "15-01-2024", "2024/01/15" + if ((has_slash || has_dash) && !has_colon) + { + static const char* const date_formats[] = { "%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", + "%m/%d/%Y", "%d-%m-%Y", "%m-%d-%Y" }; + + auto parse_date = [](std::istringstream& in, const char* fmt) { + date::year_month_day ymd; + in >> date::parse(fmt, ymd); + return !in.fail(); + }; + + if (auto fmt = tryParseFormats(date_formats, 6, parse_date)) + { + info.type = ColumnType::DATE_ONLY; + info.format = *fmt; + return info; + } + } + + // Check for TIME_ONLY formats (time without date) + // Formats: "14:30:25", "14:30:25.123", "2:30:25 PM" + if (has_colon && !has_slash && !has_dash) + { + static const char* const time_formats[] = { + "%H:%M:%S", + "%I:%M:%S %p" // 12-hour with AM/PM + }; + + auto parse_time = [](std::istringstream& in, const char* fmt) { + std::chrono::seconds time_of_day{ 0 }; + in >> date::parse(fmt, time_of_day); + return !in.fail(); + }; + + if (auto fmt = tryParseFormats(time_formats, 2, parse_time)) + { + info.type = ColumnType::TIME_ONLY; + info.format = *fmt; + info.has_fractional = (fractional_ns.count() > 0); + return info; + } + } + + // Now check for full DATETIME formats (if not already detected as date-only or time-only) auto try_format = [&](const char* fmt) -> bool { std::istringstream in{ base_str }; in.imbue(std::locale::classic()); @@ -478,4 +544,62 @@ std::optional ParseWithType(const std::string& str, const ColumnTypeInfo } } +std::optional ParseCombinedDateTime(const std::string& date_str, + const std::string& time_str, + const ColumnTypeInfo& date_info, + const ColumnTypeInfo& time_info) +{ + std::string trimmed_date = Trim(date_str); + std::string trimmed_time = Trim(time_str); + + if (trimmed_date.empty() || trimmed_time.empty()) + { + return std::nullopt; + } + + try + { + // Parse date part to get year, month, day + std::istringstream date_in{ trimmed_date }; + date_in.imbue(std::locale::classic()); + + date::year_month_day ymd; + date_in >> date::parse(date_info.format.c_str(), ymd); + + if (date_in.fail()) + { + return std::nullopt; + } + + // Parse time part to get hour, minute, second + auto [base_time_str, fractional_ns] = ExtractFractionalSeconds(trimmed_time); + if (!time_info.has_fractional) + { + fractional_ns = std::chrono::nanoseconds{ 0 }; + } + + std::istringstream time_in{ base_time_str }; + time_in.imbue(std::locale::classic()); + + std::chrono::seconds time_of_day{ 0 }; + time_in >> date::parse(time_info.format.c_str(), time_of_day); + + if (time_in.fail()) + { + return std::nullopt; + } + + // Combine date and time + auto dp = date::sys_days{ ymd }; + auto tp = dp + time_of_day + fractional_ns; + + auto duration = tp.time_since_epoch(); + return std::chrono::duration(duration).count(); + } + catch (...) + { + return std::nullopt; + } +} + } // namespace PJ::CSV diff --git a/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.h b/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.h index 3824ce334..1df7daa1b 100644 --- a/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.h +++ b/plotjuggler_plugins/DataLoadCSV/timestamp_parsing.h @@ -96,6 +96,8 @@ enum class ColumnType EPOCH_MICROS, // Numeric epoch timestamp in microseconds EPOCH_NANOS, // Numeric epoch timestamp in nanoseconds DATETIME, // Date/time string with detected format + DATE_ONLY, // Date string without time component (e.g., "2024-01-15") + TIME_ONLY, // Time string without date component (e.g., "14:30:25.123") STRING, // Non-numeric, non-datetime string UNDEFINED }; @@ -131,6 +133,23 @@ ColumnTypeInfo DetectColumnType(const std::string& str); */ std::optional ParseWithType(const std::string& str, const ColumnTypeInfo& type_info); +/** + * @brief Combine separate date and time strings into a single timestamp. + * + * Takes a date-only string and a time-only string and combines them into + * a single timestamp value. + * + * @param date_str The date string (e.g., "2024-01-15") + * @param time_str The time string (e.g., "14:30:25.123") + * @param date_info The detected type info for the date column + * @param time_info The detected type info for the time column + * @return Combined timestamp as seconds since epoch, or nullopt if parsing fails + */ +std::optional ParseCombinedDateTime(const std::string& date_str, + const std::string& time_str, + const ColumnTypeInfo& date_info, + const ColumnTypeInfo& time_info); + } // namespace PJ::CSV #endif // TIMESTAMP_PARSING_H