openpilot is an open source driver assistance system. openpilot performs the functions of Automated Lane Centering and Adaptive Cruise Control for over 200 supported car makes and models.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

570 lines
20 KiB

#include "tools/cabana/chart/chartswidget.h"
#include <algorithm>
#include <QApplication>
#include <QFutureSynchronizer>
#include <QMenu>
#include <QScrollBar>
#include <QToolBar>
#include <QtConcurrent>
#include "tools/cabana/chart/chart.h"
const int MAX_COLUMN_COUNT = 4;
const int CHART_SPACING = 4;
ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) {
align_timer = new QTimer(this);
auto_scroll_timer = new QTimer(this);
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
// toolbar
toolbar = new QToolBar(tr("Charts"), this);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
auto new_plot_btn = new ToolButton("file-plus", tr("New Chart"));
auto new_tab_btn = new ToolButton("window-stack", tr("New Tab"));
toolbar->addWidget(new_plot_btn);
toolbar->addWidget(new_tab_btn);
toolbar->addWidget(title_label = new QLabel());
title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0);
auto chart_type_action = toolbar->addAction("");
QMenu *chart_type_menu = new QMenu(this);
auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")};
for (int i = 0; i < types.size(); ++i) {
QString type_text = types[i];
chart_type_menu->addAction(type_text, this, [=]() {
settings.chart_series_type = i;
chart_type_action->setText("Type: " + type_text);
settingChanged();
});
}
chart_type_action->setText("Type: " + types[settings.chart_series_type]);
chart_type_action->setMenu(chart_type_menu);
qobject_cast<QToolButton *>(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup);
QMenu *menu = new QMenu(this);
for (int i = 0; i < MAX_COLUMN_COUNT; ++i) {
menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); });
}
columns_action = toolbar->addAction("");
columns_action->setMenu(menu);
qobject_cast<QToolButton*>(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup);
QWidget *spacer = new QWidget(this);
spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
range_slider = new LogSlider(1000, Qt::Horizontal, this);
range_slider->setFixedWidth(150 * qApp->devicePixelRatio());
range_slider->setToolTip(tr("Set the chart range"));
range_slider->setRange(1, settings.max_cached_minutes * 60);
range_slider->setSingleStep(1);
range_slider->setPageStep(60); // 1 min
range_slider_action = toolbar->addWidget(range_slider);
// zoom controls
zoom_undo_stack = new QUndoStack(this);
toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this));
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this));
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom")));
reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts")));
toolbar->addWidget(dock_btn = new ToolButton(""));
main_layout->addWidget(toolbar);
// tabbar
tabbar = new TabBar(this);
tabbar->setAutoHide(true);
tabbar->setExpanding(false);
tabbar->setDrawBase(true);
tabbar->setAcceptDrops(true);
tabbar->setChangeCurrentOnDrag(true);
tabbar->setUsesScrollButtons(true);
main_layout->addWidget(tabbar);
// charts
charts_container = new ChartsContainer(this);
charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
charts_scroll = new QScrollArea(this);
charts_scroll->viewport()->setBackgroundRole(QPalette::Base);
charts_scroll->setFrameStyle(QFrame::NoFrame);
charts_scroll->setWidgetResizable(true);
charts_scroll->setWidget(charts_container);
charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
main_layout->addWidget(charts_scroll);
// init settings
current_theme = settings.theme;
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);
updateToolBar();
align_timer->setSingleShot(true);
QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts);
QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll);
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll);
QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged);
QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged);
QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange);
QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart);
QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll);
QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset);
QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged);
QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab);
QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar);
QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab);
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
if (index != -1) updateLayout(true);
});
QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking);
setIsDocked(true);
newTab();
qApp->installEventFilter(this);
setWhatsThis(tr(R"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)"));
}
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).toInt();
for (auto &c : 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).toInt()];
tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count()));
}
}
void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) {
QFutureSynchronizer<void> future_synchronizer;
for (auto c : charts) {
future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events));
}
}
void ChartsWidget::timeRangeChanged(const std::optional<std::pair<double, double>> &time_range) {
updateToolBar();
updateState();
}
void ChartsWidget::zoomReset() {
can->setTimeRange(std::nullopt);
zoom_undo_stack->clear();
}
QRect ChartsWidget::chartVisibleRect(ChartView *chart) {
const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size());
return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size()));
}
void ChartsWidget::showValueTip(double sec) {
emit 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.isEmpty()) 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<float>(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 = range_slider->value();
updateToolBar();
updateState();
}
void ChartsWidget::setIsDocked(bool docked) {
is_docked = docked;
dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square");
dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window"));
}
void ChartsWidget::updateToolBar() {
title_label->setText(tr("Charts: %1").arg(charts.size()));
columns_action->setText(tr("Columns: %1").arg(column_count));
range_lb->setText(utils::formatSeconds(max_chart_range));
bool is_zoomed = can->timeRange().has_value();
range_lb_action->setVisible(!is_zoomed);
range_slider_action->setVisible(!is_zoomed);
undo_zoom_action->setVisible(is_zoomed);
redo_zoom_action->setVisible(is_zoomed);
reset_zoom_action->setVisible(is_zoomed);
reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : "");
remove_all_btn->setEnabled(!charts.isEmpty());
}
void ChartsWidget::settingChanged() {
if (std::exchange(current_theme, settings.theme) != current_theme) {
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
auto theme = settings.theme == DARK_THEME ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight;
for (auto c : charts) {
c->setTheme(theme);
}
}
if (range_slider->maximum() != settings.max_cached_minutes * 60) {
range_slider->setRange(1, settings.max_cached_minutes * 60);
}
for (auto c : charts) {
c->setFixedHeight(settings.chart_height);
c->setSeriesType((SeriesType)settings.chart_series_type);
c->resetChartCache();
}
}
ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) {
for (auto c : charts)
if (c->hasSignal(id, sig)) return c;
return nullptr;
}
ChartView *ChartsWidget::createChart(int pos) {
auto chart = new ChartView(can->timeRange().value_or(display_range), this);
chart->setFixedHeight(settings.chart_height);
chart->setMinimumWidth(CHART_MIN_WIDTH);
chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed);
QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start));
pos = std::clamp(pos, 0, charts.size());
charts.insert(pos, chart);
currentCharts().insert(pos, chart);
updateLayout(true);
updateToolBar();
return chart;
}
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->sigs.size() > 1) {
int pos = charts.indexOf(src_chart) + 1;
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
auto c = createChart(pos);
src_chart->chart()->removeSeries(it->series);
// Restore to the original color
it->series->setColor(it->sig->color);
c->addSeries(it->series);
c->sigs.emplace_back(std::move(*it));
c->updateAxisY();
c->updateTitle();
it = src_chart->sigs.erase(it);
}
src_chart->updateAxisY();
src_chart->updateTitle();
QTimer::singleShot(0, src_chart, &ChartView::resetChartCache);
}
}
void ChartsWidget::setColumnCount(int n) {
n = std::clamp(n, 1, MAX_COLUMN_COUNT);
if (column_count != n) {
column_count = settings.chart_column_count = n;
updateToolBar();
updateLayout();
}
}
void ChartsWidget::updateLayout(bool force) {
auto charts_layout = charts_container->charts_layout;
int n = MAX_COLUMN_COUNT;
for (; n > 1; --n) {
if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break;
}
bool show_column_cb = n > 1;
columns_action->setVisible(show_column_cb);
n = std::min(column_count, n);
auto &current_charts = currentCharts();
if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) {
current_column_count = n;
charts_container->setUpdatesEnabled(false);
for (auto c : charts) {
c->setVisible(false);
}
for (int i = 0; i < current_charts.size(); ++i) {
charts_layout->addWidget(current_charts[i], i / n, i % n);
if (current_charts[i]->sigs.empty()) {
// the chart will be resized after add signal. delay setVisible to reduce flicker.
QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); });
} else {
current_charts[i]->setVisible(true);
}
}
charts_container->setUpdatesEnabled(true);
}
}
void ChartsWidget::startAutoScroll() {
auto_scroll_timer->start(50);
}
void ChartsWidget::stopAutoScroll() {
auto_scroll_timer->stop();
auto_scroll_count = 0;
}
void ChartsWidget::doAutoScroll() {
QScrollBar *scroll = charts_scroll->verticalScrollBar();
if (auto_scroll_count < scroll->pageStep()) {
++auto_scroll_count;
}
int value = scroll->value();
QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos());
QRect area = charts_scroll->viewport()->rect();
if (pos.y() - area.top() < settings.chart_height / 2) {
scroll->setValue(value - auto_scroll_count);
} else if (area.bottom() - pos.y() < settings.chart_height / 2) {
scroll->setValue(value + auto_scroll_count);
}
bool vertical_unchanged = value == scroll->value();
if (vertical_unchanged) {
stopAutoScroll();
} else {
// mouseMoveEvent to updates the drag-selection rectangle
const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos);
const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos);
QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos,
Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt);
QApplication::sendEvent(charts_scroll->viewport(), &mm);
}
}
QSize ChartsWidget::minimumSizeHint() const {
return QSize(CHART_MIN_WIDTH * 1.5 * qApp->devicePixelRatio(), QWidget::minimumSizeHint().height());
}
void ChartsWidget::newChart() {
SignalSelector dlg(tr("New Chart"), this);
if (dlg.exec() == QDialog::Accepted) {
auto items = dlg.seletedItems();
if (!items.isEmpty()) {
auto c = createChart();
for (auto it : items) {
c->addSignal(it->msg_id, it->sig);
}
}
}
}
void ChartsWidget::removeChart(ChartView *chart) {
charts.removeOne(chart);
chart->deleteLater();
for (auto &[_, list] : tab_charts) {
list.removeOne(chart);
}
updateToolBar();
updateLayout(true);
alignCharts();
emit seriesChanged();
}
void ChartsWidget::removeAll() {
while (tabbar->count() > 1) {
tabbar->removeTab(1);
}
tab_charts.clear();
if (!charts.isEmpty()) {
for (auto c : charts) {
delete c;
}
charts.clear();
emit seriesChanged();
}
zoomReset();
}
void ChartsWidget::alignCharts() {
int plot_left = 0;
for (auto c : charts) {
plot_left = std::max(plot_left, c->y_label_width);
}
plot_left = std::max((plot_left / 10) * 10 + 10, 50);
for (auto c : charts) {
c->updatePlotArea(plot_left);
}
}
bool ChartsWidget::eventFilter(QObject *o, QEvent *e) {
if (!value_tip_visible_) return false;
if (e->type() == QEvent::MouseMove) {
bool on_tip = qobject_cast<TipLabel *>(o) != nullptr;
auto global_pos = static_cast<QMouseEvent *>(e)->globalPos();
for (const auto &c : charts) {
auto local_pos = c->mapFromGlobal(global_pos);
if (c->chart()->plotArea().contains(local_pos)) {
if (on_tip) {
showValueTip(c->secondsAtPoint(local_pos));
}
return false;
}
}
showValueTip(-1);
} else if (e->type() == QEvent::Wheel) {
if (auto tip = qobject_cast<TipLabel *>(o)) {
// Forward the event to the parent widget
QCoreApplication::sendEvent(tip->parentWidget(), e);
}
}
return false;
}
bool ChartsWidget::event(QEvent *event) {
bool back_button = false;
switch (event->type()) {
case QEvent::Resize:
updateLayout();
break;
case QEvent::MouseButtonPress:
back_button = static_cast<QMouseEvent *>(event)->button() == Qt::BackButton;
break;
case QEvent::NativeGesture:
back_button = (static_cast<QNativeGestureEvent *>(event)->value() == 180);
break;
case QEvent::WindowDeactivate:
case QEvent::FocusOut:
showValueTip(-1);
default:
break;
}
if (back_button) {
zoom_undo_stack->undo();
return true; // Return true since the event has been handled
}
return QFrame::event(event);
}
// ChartsContainer
ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) {
setAcceptDrops(true);
setBackgroundRole(QPalette::Window);
QVBoxLayout *charts_main_layout = new QVBoxLayout(this);
charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING);
charts_layout = new QGridLayout();
charts_layout->setSpacing(CHART_SPACING);
charts_main_layout->addLayout(charts_layout);
charts_main_layout->addStretch(0);
}
void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) {
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
event->acceptProposedAction();
drawDropIndicator(event->pos());
}
}
void ChartsContainer::dropEvent(QDropEvent *event) {
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
auto w = getDropAfter(event->pos());
auto chart = qobject_cast<ChartView *>(event->source());
if (w != chart) {
for (auto &[_, list] : charts_widget->tab_charts) {
list.removeOne(chart);
}
int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0;
charts_widget->currentCharts().insert(to, chart);
charts_widget->updateLayout(true);
charts_widget->updateTabBar();
event->acceptProposedAction();
chart->startAnimation();
}
drawDropIndicator({});
}
}
void ChartsContainer::paintEvent(QPaintEvent *ev) {
if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) {
QRect r = geometry();
r.setHeight(CHART_SPACING);
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
r.moveTop(insert_after->geometry().bottom());
}
QPainter p(this);
p.fillRect(r, palette().highlight());
}
}
ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const {
auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) {
auto area = c->geometry();
return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom();
});
return it == charts_widget->currentCharts().crend() ? nullptr : *it;
}