#define IMGUI_DEFINE_MATH_OPERATORS #include "tools/cabana/ui/chart/chartswidget.h" #include "tools/cabana/ui/threadpool.h" #include #include #include #include #include "tools/cabana/settings.h" #include "tools/cabana/ui/chart/chart.h" #include "tools/cabana/ui/icons.h" #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/strings.h" const int MAX_COLUMN_COUNT = 4; const int CHART_SPACING = 4; const int START_DRAG_DISTANCE = 10; const float LAYOUT_HORIZONTAL_SPACING = 6.0f; const float MIN_RANGE_SLIDER_WIDTH = 40.0f; bool LogSlider::draw(const char *label, float width) { return fusionSliderInt(label, &pos_, min_, max_, width); } ChartsWidget::ChartsWidget() { range_slider_.setRange(1, settings.max_cached_minutes * 60); tabbar_.setAutoHide(true); tabbar_.setUsesScrollButtons(true); tabbar_.setTabsClosable(true); column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); range_slider_.setValue(max_chart_range_); connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); connections_.push_back(can->seeking.connect([this](double) { updateState(); })); connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("Close Other Tabs")) { tabbar_.moveTab(index, 0); tabbar_.setCurrentIndex(0); while (tabbar_.count() > 1) removeTab(1); } ImGui::EndPopup(); } })); connections_.push_back(tabbar_.currentChanged.connect([this](int index) { if (index != -1) updateLayout(); })); setIsDocked(true); newTab(); } ChartsWidget::~ChartsWidget() = default; std::string ChartsWidget::whatsThis() const { return R"( Chart View
Click: Click to seek to a corresponding time.
Drag: Zoom into the chart.
Shift + Drag: Scrub through the chart to view values.
Right Mouse: Open the context menu.
)"; } void ChartsWidget::newTab() { static int tab_unique_id = 0; int idx = tabbar_.addTab(""); tabbar_.setTabData(idx, tab_unique_id++); tabbar_.setCurrentIndex(idx); updateTabBar(); } void ChartsWidget::removeTab(int index) { int id = tabbar_.tabData(index); for (auto &c : std::vector(tab_charts_[id])) { removeChart(c); } tab_charts_.erase(id); tabbar_.removeTab(index); updateTabBar(); } void ChartsWidget::updateTabBar() { for (int i = 0; i < tabbar_.count(); ++i) { const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); } } void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { std::vector> futures; for (auto &c : charts_) { futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); } for (auto &f : futures) f.get(); } void ChartsWidget::zoomReset() { can->setTimeRange(std::nullopt); zoom_undo_stack_.clear(); } ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { ImRect r = chart->rect(); r.ClipWith(charts_scroll_viewport_); return r; } void ChartsWidget::showValueTip(double sec) { if (chartDragActive()) sec = -1; showTip(sec); if (sec < 0 && !value_tip_visible_) return; value_tip_visible_ = sec >= 0; for (auto c : currentCharts()) { value_tip_visible_ ? c->showTip(sec) : c->hideTip(); } } void ChartsWidget::updateState() { if (charts_.empty()) return; const auto &time_range = can->timeRange(); const double cur_sec = can->currentSec(); if (!time_range.has_value()) { double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); if (pos < 0 || pos > 0.8) { display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); } double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); display_range_.second = display_range_.first + max_chart_range_; } const auto &range = time_range ? *time_range : display_range_; for (auto &c : charts_) { c->updatePlot(cur_sec, range.first, range.second); } } void ChartsWidget::setMaxChartRange(int value) { max_chart_range_ = settings.chart_range = value; updateState(); } void ChartsWidget::setIsDocked(bool docked) { is_docked_ = docked; if (!docked) float_window_init_ = true; } void ChartsWidget::drawToolBar() { beginToolbar(); float slider_width = 150.0f; const bool is_zoomed = can->timeRange().has_value(); std::vector items; items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); }}); items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); }}); const std::string title_label = "Charts: " + std::to_string(charts_.size()); items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(title_label.c_str()); ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); ImGui::Dummy(ImVec2(0.0f, 0.0f)); }}); const int type_count = (int)std::size(SERIES_TYPE_NAMES); const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { menuButton("chart_type", chart_type_text, "chart_type_menu"); if (ImGui::BeginPopup("chart_type_menu")) { for (int i = 0; i < type_count; ++i) { if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { settings.chart_series_type = i; settingChanged(); } } ImGui::EndPopup(); } }}); const std::string columns_action_text = "Columns: " + std::to_string(column_count_); if (columns_action_visible_) { items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { menuButton("columns", columns_action_text, "columns_menu"); if (ImGui::BeginPopup("columns_menu")) { for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); } ImGui::EndPopup(); } }}); } const size_t spacer_index = items.size(); size_t slider_index = (size_t)-1; const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); std::string reset_zoom_text; if (!is_zoomed) { items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(range_lb.c_str()); }}); slider_index = items.size(); items.push_back({slider_width, [this, &slider_width]() { if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); ImGui::SetItemTooltip("Set the chart range"); }}); } else { char buf[64]; snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); reset_zoom_text = buf; items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); }}); items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); }}); } items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { ImGui::BeginDisabled(charts_.empty()); if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); ImGui::EndDisabled(); }}); const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); }}); if (slider_index != (size_t)-1) { const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); if (shrink > 0.0f) { slider_width -= shrink; items[slider_index].width = slider_width; } } drawToolbar(items, spacer_index); endToolbar(); } void ChartsWidget::settingChanged() { if (range_slider_.maximum() != settings.max_cached_minutes * 60) { range_slider_.setRange(1, settings.max_cached_minutes * 60); } for (auto &c : charts_) { c->setSeriesType((SeriesType)settings.chart_series_type); } } ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { for (auto &c : charts_) if (c->hasSignal(id, sig)) return c.get(); return nullptr; } ChartView *ChartsWidget::createChart(int pos) { auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); ChartView *ptr = chart.get(); pos = std::clamp(pos, 0, (int)charts_.size()); charts_.insert(charts_.begin() + pos, std::move(chart)); currentCharts().insert(currentCharts().begin() + pos, ptr); updateLayout(); return ptr; } void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { ChartView *chart = findChart(id, sig); if (show && !chart) { chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); chart->addSignal(id, sig); updateState(); } else if (!show && chart) { chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); } } void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->signals().size() > 1) { auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); const int pos = it - charts_.begin() + 1; for (auto &s : src_chart->takeExtraSignals()) { createChart(pos)->adoptSignal(std::move(s)); } updateState(); } } std::vector ChartsWidget::serializeChartIds() const { std::vector chart_ids; for (auto &c : charts_) { std::string ids; for (const auto &s : c->signals()) { if (!ids.empty()) ids += ','; ids += s.msg_id.toString() + "|" + s.sig->name; } chart_ids.push_back(ids); } std::reverse(chart_ids.begin(), chart_ids.end()); return chart_ids; } void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { for (const auto &chart_id : chart_ids) { int index = 0; for (const auto &part : utils::split(chart_id, ',')) { const size_t sep = part.find('|'); if (sep == std::string::npos) continue; MessageId msg_id = MessageId::fromString(part.substr(0, sep)); if (auto *msg = dbc()->msg(msg_id)) if (auto *sig = msg->sig(part.substr(sep + 1))) showChart(msg_id, sig, true, index++ > 0); } } } void ChartsWidget::setColumnCount(int n) { n = std::clamp(n, 1, MAX_COLUMN_COUNT); if (column_count_ != n) { column_count_ = settings.chart_column_count = n; updateLayout(); } } void ChartsWidget::updateLayout() { const float container_width = charts_container_.geometry().GetWidth(); if (container_width <= 0) return; int n = MAX_COLUMN_COUNT; for (; n > 1; --n) { if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; } columns_action_visible_ = n > 1; current_column_count_ = std::min(column_count_, n); } void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { stopAutoScroll(); drag_ = {.source = chart, .press_pos = global_pos}; showValueTip(-1); drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); } void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { if (!drag_.active) { ImVec2 d = global_pos - drag_.press_pos; if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; drag_.active = true; drag_preview_visible_ = true; } drag_preview_pos_ = global_pos + ImVec2(5, 5); int tab = tabbar_.tabAt(global_pos); if (tab >= 0 && tab != tabbar_.currentIndex()) { tabbar_.setCurrentIndex(tab); } ChartView *target = nullptr; for (auto c : currentCharts()) { if (c != drag_.source && c->rect().Contains(global_pos)) { target = c; break; } } if (std::exchange(drop_target_, target) != target) { for (auto &c : charts_) c->setDropHighlight(c.get() == target); } bool in_viewport = charts_scroll_viewport_.Contains(global_pos); bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); if (in_viewport) { startAutoScroll(global_pos); } } void ChartsWidget::cancelChartDrag() { drag_ = {}; stopAutoScroll(); drag_preview_visible_ = false; charts_container_.setDropIndicator({}); if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); } void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { ChartView *source = drag_.source; bool active = drag_.active; ChartView *target = drop_target_; cancelChartDrag(); if (!active) return; bool in_viewport = charts_scroll_viewport_.Contains(global_pos); if (target) { target->takeSignalsFrom(source); } else if (in_viewport && !charts_container_.childAt(global_pos)) { auto w = charts_container_.getDropAfter(global_pos); if (w != source) { for (auto &[_, list] : tab_charts_) { list.erase(std::remove(list.begin(), list.end(), source), list.end()); } auto &cur = currentCharts(); int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; cur.insert(cur.begin() + to, source); updateLayout(); updateTabBar(); } } } void ChartsWidget::drawDragPreview() { if (!drag_preview_visible_ || !drag_.source) return; ImGui::SetNextWindowPos(drag_preview_pos_); ImGui::SetNextWindowSize(drag_preview_size_); ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { drag_.source->drawGhost(drag_preview_size_.x); } ImGui::End(); ImGui::PopStyleVar(3); } void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { auto_scroll_pos_ = global_pos; if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; auto_scroll_timer_active_ = true; } void ChartsWidget::stopAutoScroll() { auto_scroll_timer_active_ = false; auto_scroll_count_ = 0; } void ChartsWidget::doAutoScroll() { if (!charts_scroll_) return; const int page_step = charts_scroll_viewport_.GetHeight(); if (auto_scroll_count_ < page_step) { ++auto_scroll_count_; } int value = charts_scroll_->Scroll.y; ImVec2 pos = auto_scroll_pos_; ImRect area = charts_scroll_viewport_; int new_value = value; if (pos.y - area.Min.y < settings.chart_height / 2) { new_value = value - auto_scroll_count_; } else if (area.Max.y - pos.y < settings.chart_height / 2) { new_value = value + auto_scroll_count_; } new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); if (value == new_value) { stopAutoScroll(); } else if (chartDragActive()) { dragChartMove(auto_scroll_pos_); } } void ChartsWidget::newChart() { execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { const auto &items = dlg.selectedItems(); if (!items.empty()) { auto c = createChart(); for (const auto &it : items) { c->addSignal(it.msg_id, it.sig); } updateState(); } }); } void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { signal_selector_ = std::move(dlg); signal_selector_owner_ = owner; signal_selector_accepted_ = std::move(accepted); signal_selector_->open(); } void ChartsWidget::removeChart(ChartView *chart) { if (drag_.source == chart) cancelChartDrag(); if (drop_target_ == chart) drop_target_ = nullptr; if (signal_selector_owner_ == chart) { signal_selector_owner_ = nullptr; signal_selector_accepted_ = nullptr; } auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); if (it != charts_.end()) { deleted_charts_.push_back(std::move(*it)); charts_.erase(it); } for (auto &[_, list] : tab_charts_) { list.erase(std::remove(list.begin(), list.end(), chart), list.end()); } updateLayout(); seriesChanged(); } void ChartsWidget::removeAll() { while (tabbar_.count() > 1) { tabbar_.removeTab(1); } std::vector all; for (auto &c : charts_) all.push_back(c.get()); for (auto c : all) removeChart(c); tab_charts_.clear(); zoomReset(); } void ChartsWidget::handleEvents() { if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { zoom_undo_stack_.undo(); } if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { if (chartDragActive()) cancelChartDrag(); showValueTip(-1); } if (chartDragActive()) { if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { dragChartMove(ImGui::GetMousePos()); } else { dragChartRelease(ImGui::GetMousePos()); } } if (!value_tip_visible_) return; const ImVec2 delta = ImGui::GetIO().MouseDelta; if (!any_plot_hovered_ && (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { showValueTip(-1); } } void ChartsWidget::draw() { deleted_charts_.clear(); if (float_window_init_ && !is_docked_) { float_window_init_ = false; const ImGuiViewport *viewport = ImGui::GetMainViewport(); const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); ImGui::SetWindowSize(size); ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); } ImGui::PushID(this); if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; doAutoScroll(); } handleEvents(); drawToolBar(); tabbar_.draw(); any_plot_hovered_ = false; if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { charts_scroll_ = ImGui::GetCurrentWindow(); charts_scroll_viewport_ = charts_scroll_->InnerRect; charts_container_.draw(); } ImGui::EndChild(); drawDragPreview(); if (signal_selector_ && !signal_selector_->draw()) { auto dlg = std::move(signal_selector_); auto accepted = std::move(signal_selector_accepted_); signal_selector_owner_ = nullptr; if (dlg->accepted() && accepted) accepted(*dlg); } ImGui::PopID(); } void ChartsContainer::draw() { ImGuiWindow *window = ImGui::GetCurrentWindow(); const ImVec2 start = ImGui::GetCursorScreenPos(); geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); charts_widget_->updateLayout(); const int n = std::max(charts_widget_->current_column_count_, 1); const float spacing = CHART_SPACING; const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); auto current_charts = charts_widget_->currentCharts(); float bottom = origin.y; const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); for (int i = 0; i < current_charts.size(); ++i) { ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); ImGui::SetCursorScreenPos(pos); current_charts[i]->draw(width); bottom = std::max(bottom, pos.y + settings.chart_height); if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; } if (aligned) ImPlot::EndAlignedPlots(); ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); geometry_.Max.y = bottom + CHART_SPACING; drawDropIndicator(); } void ChartsContainer::drawDropIndicator() { if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { ImRect r = geometry_; r.Max.y = r.Min.y + CHART_SPACING; if (auto insert_after = getDropAfter(drop_indicator_pos_)) { float h = r.GetHeight(); r.Min.y = insert_after->rect().Max.y; r.Max.y = r.Min.y + h; } ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); } } ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { const auto &charts = charts_widget_->currentCharts(); auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { const ImRect &area = c->rect(); return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; }); return it == charts.crend() ? nullptr : *it; } ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { for (auto c : charts_widget_->currentCharts()) { if (c->rect().Contains(pos)) return c; } return nullptr; }