mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-08 11:25:51 +08:00
937 lines
36 KiB
C++
937 lines
36 KiB
C++
#include "tools/jotpluggler/internal.h"
|
|
|
|
#include "implot.h"
|
|
#include "imgui_internal.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <limits>
|
|
|
|
struct PlotBounds {
|
|
double x_min = 0.0;
|
|
double x_max = 1.0;
|
|
double y_min = 0.0;
|
|
double y_max = 1.0;
|
|
};
|
|
|
|
bool curve_has_samples(const AppSession &session, const Curve &curve) {
|
|
if (curve_has_local_samples(curve)) return true;
|
|
if (curve.name.empty() || curve.name.front() != '/') {
|
|
return false;
|
|
}
|
|
const RouteSeries *series = app_find_route_series(session, curve.name);
|
|
return series != nullptr && series->times.size() > 1 && series->times.size() == series->values.size();
|
|
}
|
|
|
|
void extend_range(const std::vector<double> &values, bool *found, double *min_value, double *max_value) {
|
|
if (values.empty()) {
|
|
return;
|
|
}
|
|
const auto [min_it, max_it] = std::minmax_element(values.begin(), values.end());
|
|
if (!*found) {
|
|
*min_value = *min_it;
|
|
*max_value = *max_it;
|
|
*found = true;
|
|
return;
|
|
}
|
|
*min_value = std::min(*min_value, *min_it);
|
|
*max_value = std::max(*max_value, *max_it);
|
|
}
|
|
|
|
void ensure_non_degenerate_range(double *min_value, double *max_value, double pad_fraction, double fallback_pad) {
|
|
if (*max_value <= *min_value) {
|
|
const double pad = std::max(std::abs(*min_value) * 0.1, fallback_pad);
|
|
*min_value -= pad;
|
|
*max_value += pad;
|
|
return;
|
|
}
|
|
const double span = *max_value - *min_value;
|
|
const double pad = std::max(span * pad_fraction, fallback_pad);
|
|
*min_value -= pad;
|
|
*max_value += pad;
|
|
}
|
|
|
|
struct PreparedCurve {
|
|
int pane_curve_index = -1;
|
|
std::string label;
|
|
std::array<uint8_t, 3> color = {160, 170, 180};
|
|
float line_weight = 2.0f;
|
|
bool stairs = false;
|
|
const EnumInfo *enum_info = nullptr;
|
|
SeriesFormat display_info;
|
|
std::optional<double> legend_value;
|
|
std::vector<double> xs;
|
|
std::vector<double> ys;
|
|
};
|
|
|
|
struct StateBlock {
|
|
double t0 = 0.0;
|
|
double t1 = 0.0;
|
|
int value = 0;
|
|
std::string label;
|
|
};
|
|
|
|
struct PaneValueFormatContext {
|
|
SeriesFormat format;
|
|
bool valid = false;
|
|
};
|
|
|
|
bool curves_are_bool_like(const std::vector<PreparedCurve> &prepared_curves) {
|
|
if (prepared_curves.empty()) {
|
|
return false;
|
|
}
|
|
for (const PreparedCurve &curve : prepared_curves) {
|
|
if (!curve.display_info.integer_like || curve.ys.empty()) {
|
|
return false;
|
|
}
|
|
bool found_finite = false;
|
|
for (double value : curve.ys) {
|
|
if (!std::isfinite(value)) continue;
|
|
found_finite = true;
|
|
if (std::abs(value) > 0.01 && std::abs(value - 1.0) > 0.01) {
|
|
return false;
|
|
}
|
|
}
|
|
if (!found_finite) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
ImU32 state_block_color(int value, float alpha = 1.0f) {
|
|
static constexpr std::array<std::array<uint8_t, 3>, 8> kPalette = {{
|
|
{{111, 143, 175}},
|
|
{{0, 163, 108}},
|
|
{{255, 195, 0}},
|
|
{{199, 0, 57}},
|
|
{{123, 97, 255}},
|
|
{{0, 150, 136}},
|
|
{{214, 48, 49}},
|
|
{{52, 73, 94}},
|
|
}};
|
|
const size_t index = static_cast<size_t>(std::abs(value)) % kPalette.size();
|
|
return ImGui::GetColorU32(color_rgb(kPalette[index], alpha));
|
|
}
|
|
|
|
std::string state_block_label(const PreparedCurve &curve, int value) {
|
|
if (curve.enum_info != nullptr && value >= 0 && static_cast<size_t>(value) < curve.enum_info->names.size()) {
|
|
const std::string &name = curve.enum_info->names[static_cast<size_t>(value)];
|
|
if (!name.empty()) {
|
|
return name;
|
|
}
|
|
}
|
|
return std::to_string(value);
|
|
}
|
|
|
|
std::vector<StateBlock> build_state_blocks(const PreparedCurve &curve) {
|
|
std::vector<StateBlock> blocks;
|
|
if (curve.xs.size() < 2 || curve.xs.size() != curve.ys.size()) {
|
|
return blocks;
|
|
}
|
|
|
|
int current_value = static_cast<int>(std::llround(curve.ys.front()));
|
|
double start_time = curve.xs.front();
|
|
for (size_t i = 1; i < curve.xs.size(); ++i) {
|
|
const int value = static_cast<int>(std::llround(curve.ys[i]));
|
|
if (value == current_value) {
|
|
continue;
|
|
}
|
|
const double end_time = curve.xs[i];
|
|
if (end_time > start_time) {
|
|
blocks.push_back(StateBlock{
|
|
.t0 = start_time,
|
|
.t1 = end_time,
|
|
.value = current_value,
|
|
.label = state_block_label(curve, current_value),
|
|
});
|
|
}
|
|
current_value = value;
|
|
start_time = end_time;
|
|
}
|
|
|
|
const double final_time = curve.xs.back();
|
|
if (final_time >= start_time) {
|
|
blocks.push_back(StateBlock{
|
|
.t0 = start_time,
|
|
.t1 = final_time,
|
|
.value = current_value,
|
|
.label = state_block_label(curve, current_value),
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
void app_decimate_samples_impl(const std::vector<double> &xs_in,
|
|
const std::vector<double> &ys_in,
|
|
int max_points,
|
|
std::vector<double> *xs_out,
|
|
std::vector<double> *ys_out) {
|
|
|
|
const size_t bucket_count = std::max<size_t>(1, static_cast<size_t>(max_points / 4));
|
|
const size_t bucket_size = std::max<size_t>(
|
|
1,
|
|
static_cast<size_t>(std::ceil(static_cast<double>(xs_in.size()) / static_cast<double>(bucket_count))));
|
|
xs_out->reserve(bucket_count * 4 + 2);
|
|
ys_out->reserve(bucket_count * 4 + 2);
|
|
|
|
size_t last_index = std::numeric_limits<size_t>::max();
|
|
auto append_index = [&](size_t index) {
|
|
if (index >= xs_in.size() || index == last_index) {
|
|
return;
|
|
}
|
|
xs_out->push_back(xs_in[index]);
|
|
ys_out->push_back(ys_in[index]);
|
|
last_index = index;
|
|
};
|
|
|
|
for (size_t start = 0; start < xs_in.size(); start += bucket_size) {
|
|
const size_t end = std::min(xs_in.size(), start + bucket_size);
|
|
size_t min_index = start;
|
|
size_t max_index = start;
|
|
for (size_t index = start + 1; index < end; ++index) {
|
|
if (ys_in[index] < ys_in[min_index]) {
|
|
min_index = index;
|
|
}
|
|
if (ys_in[index] > ys_in[max_index]) {
|
|
max_index = index;
|
|
}
|
|
}
|
|
|
|
std::array<size_t, 4> indices = {start, min_index, max_index, end - 1};
|
|
std::sort(indices.begin(), indices.end());
|
|
for (size_t index : indices) {
|
|
append_index(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
void app_decimate_samples(const std::vector<double> &xs_in,
|
|
const std::vector<double> &ys_in,
|
|
int max_points,
|
|
std::vector<double> *xs_out,
|
|
std::vector<double> *ys_out) {
|
|
xs_out->clear();
|
|
ys_out->clear();
|
|
if (xs_in.empty() || xs_in.size() != ys_in.size()) {
|
|
return;
|
|
}
|
|
if (max_points <= 0 || static_cast<int>(xs_in.size()) <= max_points) {
|
|
*xs_out = xs_in;
|
|
*ys_out = ys_in;
|
|
return;
|
|
}
|
|
app_decimate_samples_impl(xs_in, ys_in, max_points, xs_out, ys_out);
|
|
}
|
|
|
|
void app_decimate_samples(std::vector<double> &&xs_in,
|
|
std::vector<double> &&ys_in,
|
|
int max_points,
|
|
std::vector<double> *xs_out,
|
|
std::vector<double> *ys_out) {
|
|
xs_out->clear();
|
|
ys_out->clear();
|
|
if (xs_in.empty() || xs_in.size() != ys_in.size()) {
|
|
return;
|
|
}
|
|
if (max_points <= 0 || static_cast<int>(xs_in.size()) <= max_points) {
|
|
*xs_out = std::move(xs_in);
|
|
*ys_out = std::move(ys_in);
|
|
return;
|
|
}
|
|
app_decimate_samples_impl(xs_in, ys_in, max_points, xs_out, ys_out);
|
|
}
|
|
|
|
std::optional<double> app_sample_xy_value_at_time(const std::vector<double> &xs,
|
|
const std::vector<double> &ys,
|
|
bool stairs,
|
|
double tm) {
|
|
if (xs.size() < 2 || xs.size() != ys.size()) {
|
|
return std::nullopt;
|
|
}
|
|
if (tm <= xs.front()) return ys.front();
|
|
if (tm >= xs.back()) return ys.back();
|
|
|
|
const auto upper = std::lower_bound(xs.begin(), xs.end(), tm);
|
|
if (upper == xs.begin()) return ys.front();
|
|
if (upper == xs.end()) return ys.back();
|
|
|
|
const size_t upper_index = static_cast<size_t>(std::distance(xs.begin(), upper));
|
|
const size_t lower_index = upper_index - 1;
|
|
const double x0 = xs[lower_index];
|
|
const double x1 = xs[upper_index];
|
|
const double y0 = ys[lower_index];
|
|
const double y1 = ys[upper_index];
|
|
if (std::abs(tm - x1) < 1.0e-9) return y1;
|
|
if (stairs || x1 <= x0) return y0;
|
|
const double alpha = (tm - x0) / (x1 - x0);
|
|
return y0 + (y1 - y0) * alpha;
|
|
}
|
|
|
|
int format_numeric_axis_tick(double value, char *buf, int size, void *user_data) {
|
|
const auto *ctx = static_cast<const PaneValueFormatContext *>(user_data);
|
|
if (ctx == nullptr || !ctx->valid) {
|
|
return std::snprintf(buf, size, "%.6g", value);
|
|
}
|
|
if (ctx->format.integer_like) {
|
|
const double nearest_int = std::round(value);
|
|
if (std::abs(value - nearest_int) > 1.0e-6) {
|
|
int decimals = 1;
|
|
while (decimals < 4) {
|
|
const double scale = std::pow(10.0, decimals);
|
|
const double rounded = std::round(value * scale) / scale;
|
|
if (std::abs(value - rounded) <= 1.0e-6) {
|
|
break;
|
|
}
|
|
++decimals;
|
|
}
|
|
return std::snprintf(buf, size, "%.*f", decimals, value);
|
|
}
|
|
}
|
|
return std::snprintf(buf, size, ctx->format.fmt, value);
|
|
}
|
|
|
|
void merge_pane_value_format(PaneValueFormatContext *ctx, const SeriesFormat &format) {
|
|
if (!ctx->valid) {
|
|
ctx->format = format;
|
|
ctx->valid = true;
|
|
return;
|
|
}
|
|
ctx->format.has_negative = ctx->format.has_negative || format.has_negative;
|
|
ctx->format.digits_before = std::max(ctx->format.digits_before, format.digits_before);
|
|
ctx->format.decimals = std::max(ctx->format.decimals, format.decimals);
|
|
ctx->format.integer_like = ctx->format.decimals == 0;
|
|
const int sign_width = ctx->format.has_negative ? 1 : 0;
|
|
const int dot_width = ctx->format.decimals > 0 ? 1 : 0;
|
|
ctx->format.total_width = sign_width + ctx->format.digits_before + dot_width + ctx->format.decimals;
|
|
std::snprintf(ctx->format.fmt, sizeof(ctx->format.fmt), "%%%d.%df",
|
|
ctx->format.total_width, ctx->format.decimals);
|
|
}
|
|
|
|
std::string curve_legend_label(const PreparedCurve &curve, bool has_cursor_time, size_t label_width) {
|
|
if (!has_cursor_time) return curve.label;
|
|
if (!curve.legend_value.has_value()) return curve.label;
|
|
const std::string value_text = format_display_value(*curve.legend_value, curve.display_info, curve.enum_info);
|
|
if (value_text.empty()) return curve.label;
|
|
const size_t padded_width = std::max(label_width, curve.label.size());
|
|
return curve.label + std::string(padded_width - curve.label.size() + 2, ' ') + value_text;
|
|
}
|
|
|
|
bool build_curve_series(const AppSession &session,
|
|
const Curve &curve,
|
|
const UiState &state,
|
|
int max_points,
|
|
PreparedCurve *prepared) {
|
|
std::vector<double> xs;
|
|
std::vector<double> ys;
|
|
if (curve_has_local_samples(curve)) {
|
|
xs = curve.xs;
|
|
ys = curve.ys;
|
|
} else {
|
|
const RouteSeries *series = app_find_route_series(session, curve.name);
|
|
if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) {
|
|
return false;
|
|
}
|
|
|
|
size_t begin_index = 0;
|
|
size_t end_index = series->times.size();
|
|
if (state.has_shared_range && state.x_view_max > state.x_view_min) {
|
|
auto begin_it = std::lower_bound(series->times.begin(), series->times.end(), state.x_view_min);
|
|
auto end_it = std::upper_bound(series->times.begin(), series->times.end(), state.x_view_max);
|
|
begin_index = begin_it == series->times.begin() ? 0 : static_cast<size_t>(std::distance(series->times.begin(), begin_it - 1));
|
|
end_index = end_it == series->times.end() ? series->times.size() : static_cast<size_t>(std::distance(series->times.begin(), end_it + 1));
|
|
end_index = std::min(end_index, series->times.size());
|
|
}
|
|
if (end_index <= begin_index + 1) return false;
|
|
xs.assign(series->times.begin() + begin_index, series->times.begin() + end_index);
|
|
ys.assign(series->values.begin() + begin_index, series->values.begin() + end_index);
|
|
}
|
|
|
|
std::vector<double> transformed_xs;
|
|
std::vector<double> transformed_ys;
|
|
if (curve.derivative) {
|
|
if (xs.size() < 2) return false;
|
|
transformed_xs.reserve(xs.size() - 1);
|
|
transformed_ys.reserve(ys.size() - 1);
|
|
for (size_t i = 1; i < xs.size(); ++i) {
|
|
const double dt = curve.derivative_dt > 0.0 ? curve.derivative_dt : (xs[i] - xs[i - 1]);
|
|
if (dt <= 0.0) continue;
|
|
transformed_xs.push_back(xs[i]);
|
|
transformed_ys.push_back((ys[i] - ys[i - 1]) / dt);
|
|
}
|
|
} else {
|
|
transformed_xs = std::move(xs);
|
|
transformed_ys = std::move(ys);
|
|
}
|
|
|
|
if (transformed_xs.size() < 2 || transformed_xs.size() != transformed_ys.size()) {
|
|
return false;
|
|
}
|
|
|
|
for (double &value : transformed_ys) {
|
|
value = value * curve.value_scale + curve.value_offset;
|
|
}
|
|
|
|
prepared->label = app_curve_display_name(curve);
|
|
prepared->color = curve.color;
|
|
prepared->line_weight = curve.derivative ? 1.8f : 2.25f;
|
|
if (!curve.derivative
|
|
&& curve.value_scale == 1.0
|
|
&& curve.value_offset == 0.0
|
|
&& !curve_has_local_samples(curve)
|
|
&& !curve.name.empty()
|
|
&& curve.name.front() == '/') {
|
|
auto it = session.route_data.enum_info.find(curve.name);
|
|
if (it != session.route_data.enum_info.end()) {
|
|
prepared->enum_info = &it->second;
|
|
}
|
|
}
|
|
if (prepared->enum_info != nullptr) {
|
|
prepared->display_info = compute_series_format(transformed_ys, true);
|
|
} else if (!curve_has_local_samples(curve)
|
|
&& !curve.derivative
|
|
&& curve.value_scale == 1.0
|
|
&& curve.value_offset == 0.0
|
|
&& !curve.name.empty()
|
|
&& curve.name.front() == '/') {
|
|
auto display_it = session.route_data.series_formats.find(curve.name);
|
|
if (display_it != session.route_data.series_formats.end()) {
|
|
prepared->display_info = display_it->second;
|
|
} else {
|
|
prepared->display_info = compute_series_format(transformed_ys, false);
|
|
}
|
|
} else {
|
|
prepared->display_info = compute_series_format(transformed_ys, false);
|
|
}
|
|
const bool stairs = !curve.derivative && prepared->display_info.integer_like;
|
|
if (state.has_tracker_time) {
|
|
prepared->legend_value = app_sample_xy_value_at_time(transformed_xs, transformed_ys, stairs, state.tracker_time);
|
|
}
|
|
if (stairs) {
|
|
prepared->xs = std::move(transformed_xs);
|
|
prepared->ys = std::move(transformed_ys);
|
|
} else {
|
|
app_decimate_samples(std::move(transformed_xs), std::move(transformed_ys), max_points, &prepared->xs, &prepared->ys);
|
|
}
|
|
prepared->stairs = stairs;
|
|
return prepared->xs.size() > 1 && prepared->xs.size() == prepared->ys.size();
|
|
}
|
|
|
|
bool draw_pane_close_button_overlay() {
|
|
const ImVec2 window_pos = ImGui::GetWindowPos();
|
|
const ImVec2 content_min = ImGui::GetWindowContentRegionMin();
|
|
const ImVec2 content_max = ImGui::GetWindowContentRegionMax();
|
|
const ImRect rect(ImVec2(window_pos.x + content_max.x - 42.0f, window_pos.y + content_min.y + 4.0f),
|
|
ImVec2(window_pos.x + content_max.x - 4.0f, window_pos.y + content_min.y + 42.0f));
|
|
const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false);
|
|
const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left);
|
|
if (hovered) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
ImDrawList *draw_list = ImGui::GetWindowDrawList();
|
|
const float pad = 11.0f;
|
|
const ImU32 color = hovered || held
|
|
? ImGui::GetColorU32(color_rgb(72, 79, 88))
|
|
: ImGui::GetColorU32(color_rgb(138, 146, 156));
|
|
draw_list->AddLine(ImVec2(rect.Min.x + pad, rect.Min.y + pad),
|
|
ImVec2(rect.Max.x - pad, rect.Max.y - pad),
|
|
color,
|
|
2.4f);
|
|
draw_list->AddLine(ImVec2(rect.Min.x + pad, rect.Max.y - pad),
|
|
ImVec2(rect.Max.x - pad, rect.Min.y + pad),
|
|
color,
|
|
2.4f);
|
|
return hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left);
|
|
}
|
|
|
|
void draw_pane_frame_overlay() {
|
|
const ImVec2 window_pos = ImGui::GetWindowPos();
|
|
const ImVec2 content_min = ImGui::GetWindowContentRegionMin();
|
|
const ImVec2 content_max = ImGui::GetWindowContentRegionMax();
|
|
const ImRect frame_rect(ImVec2(window_pos.x + content_min.x, window_pos.y + content_min.y),
|
|
ImVec2(window_pos.x + content_max.x, window_pos.y + content_max.y));
|
|
ImGui::GetWindowDrawList()->AddRect(frame_rect.Min,
|
|
frame_rect.Max,
|
|
ImGui::GetColorU32(color_rgb(186, 190, 196)),
|
|
0.0f,
|
|
0,
|
|
1.0f);
|
|
}
|
|
|
|
PlotBounds compute_plot_bounds(const Pane &pane,
|
|
const std::vector<PreparedCurve> &prepared_curves,
|
|
const UiState &state) {
|
|
PlotBounds bounds;
|
|
bounds.x_min = state.has_shared_range ? state.x_view_min : 0.0;
|
|
bounds.x_max = state.has_shared_range ? state.x_view_max : 1.0;
|
|
if (bounds.x_max <= bounds.x_min) {
|
|
bounds.x_max = bounds.x_min + 1.0;
|
|
}
|
|
|
|
bool found = false;
|
|
double min_value = 0.0;
|
|
double max_value = 1.0;
|
|
for (const PreparedCurve &curve : prepared_curves) {
|
|
extend_range(curve.ys, &found, &min_value, &max_value);
|
|
}
|
|
if (!found) {
|
|
min_value = 0.0;
|
|
max_value = 1.0;
|
|
}
|
|
if (curves_are_bool_like(prepared_curves)) {
|
|
min_value = std::min(min_value, 0.0);
|
|
max_value = std::max(max_value, 1.0);
|
|
}
|
|
ensure_non_degenerate_range(&min_value, &max_value, PLOT_Y_PADDING_FRACTION, 0.1);
|
|
if (pane.range.has_y_limit_min) {
|
|
min_value = pane.range.y_limit_min;
|
|
}
|
|
if (pane.range.has_y_limit_max) {
|
|
max_value = pane.range.y_limit_max;
|
|
}
|
|
ensure_non_degenerate_range(&min_value, &max_value, 0.0, 0.1);
|
|
bounds.y_min = min_value;
|
|
bounds.y_max = max_value;
|
|
return bounds;
|
|
}
|
|
|
|
void draw_state_blocks_pane(const std::vector<PreparedCurve> &prepared_curves, UiState *state) {
|
|
if (prepared_curves.empty() || !state->has_shared_range || state->x_view_max <= state->x_view_min) {
|
|
return;
|
|
}
|
|
|
|
ImDrawList *draw_list = ImPlot::GetPlotDrawList();
|
|
const ImVec2 plot_min = ImPlot::GetPlotPos();
|
|
const ImVec2 plot_size = ImPlot::GetPlotSize();
|
|
const int curve_count = static_cast<int>(prepared_curves.size());
|
|
if (plot_size.x <= 2.0f || plot_size.y <= 2.0f || curve_count <= 0) {
|
|
return;
|
|
}
|
|
|
|
float label_width = 0.0f;
|
|
if (curve_count > 1) {
|
|
for (const PreparedCurve &curve : prepared_curves) {
|
|
label_width = std::max(label_width, ImGui::CalcTextSize(curve.label.c_str()).x);
|
|
}
|
|
label_width = std::clamp(label_width + 14.0f, 72.0f, std::min(160.0f, plot_size.x * 0.35f));
|
|
}
|
|
|
|
const float row_height = plot_size.y / static_cast<float>(curve_count);
|
|
const float blocks_min_x = plot_min.x + label_width;
|
|
const float blocks_max_x = plot_min.x + plot_size.x;
|
|
const float blocks_width = std::max(1.0f, blocks_max_x - blocks_min_x);
|
|
const double x_span = std::max(1.0e-9, state->x_view_max - state->x_view_min);
|
|
|
|
struct HoveredBlock {
|
|
int curve_index = -1;
|
|
StateBlock block;
|
|
};
|
|
std::optional<HoveredBlock> hovered;
|
|
|
|
const ImVec2 mouse_pos = ImGui::GetMousePos();
|
|
const bool plot_hovered = ImPlot::IsPlotHovered();
|
|
|
|
for (int curve_index = 0; curve_index < curve_count; ++curve_index) {
|
|
const PreparedCurve &curve = prepared_curves[static_cast<size_t>(curve_index)];
|
|
const float y0 = plot_min.y + row_height * static_cast<float>(curve_index);
|
|
const float y1 = y0 + row_height;
|
|
const std::vector<StateBlock> blocks = build_state_blocks(curve);
|
|
|
|
if (curve_index > 0) {
|
|
draw_list->AddLine(ImVec2(plot_min.x, y0), ImVec2(plot_min.x + plot_size.x, y0),
|
|
IM_COL32(210, 214, 220, 255), 1.0f);
|
|
}
|
|
if (curve_count > 1) {
|
|
draw_list->AddLine(ImVec2(blocks_min_x, y0), ImVec2(blocks_min_x, y1),
|
|
IM_COL32(210, 214, 220, 255), 1.0f);
|
|
const float label_left = plot_min.x + 6.0f;
|
|
const float label_right = std::max(label_left + 12.0f, blocks_min_x - 6.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(120, 128, 138));
|
|
ImGui::RenderTextEllipsis(draw_list,
|
|
ImVec2(label_left, y0 + 4.0f),
|
|
ImVec2(label_right, y1 - 4.0f),
|
|
label_right,
|
|
curve.label.c_str(),
|
|
nullptr,
|
|
nullptr);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
for (const StateBlock &block : blocks) {
|
|
const double visible_t0 = std::max(block.t0, state->x_view_min);
|
|
const double visible_t1 = std::min(block.t1, state->x_view_max);
|
|
if (visible_t1 <= visible_t0) {
|
|
continue;
|
|
}
|
|
const float x0 = blocks_min_x + static_cast<float>((visible_t0 - state->x_view_min) / x_span) * blocks_width;
|
|
const float x1 = blocks_min_x + static_cast<float>((visible_t1 - state->x_view_min) / x_span) * blocks_width;
|
|
const ImU32 fill_color = state_block_color(block.value, 0.15f);
|
|
const ImU32 line_color = state_block_color(block.value, 0.90f);
|
|
draw_list->AddRectFilled(ImVec2(x0, y0), ImVec2(std::max(x1, x0 + 1.0f), y1), fill_color);
|
|
draw_list->AddLine(ImVec2(x0, y0), ImVec2(x0, y1), line_color, 2.0f);
|
|
|
|
const float block_width = x1 - x0;
|
|
if (block_width > 14.0f) {
|
|
const float text_left = x0 + 6.0f;
|
|
const float text_right = x1 - 6.0f;
|
|
if (text_right > text_left) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(state_block_color(block.value, 0.80f)));
|
|
ImGui::RenderTextEllipsis(draw_list,
|
|
ImVec2(text_left, y0 + 4.0f),
|
|
ImVec2(text_right, y1 - 4.0f),
|
|
text_right,
|
|
block.label.c_str(),
|
|
nullptr,
|
|
nullptr);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
|
|
if (plot_hovered && mouse_pos.x >= blocks_min_x && mouse_pos.x <= blocks_max_x && mouse_pos.y >= y0 && mouse_pos.y <= y1) {
|
|
const double hover_time = state->x_view_min + static_cast<double>((mouse_pos.x - blocks_min_x) / blocks_width) * x_span;
|
|
if (hover_time >= block.t0 && hover_time <= block.t1) {
|
|
hovered = HoveredBlock{
|
|
.curve_index = curve_index,
|
|
.block = block,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hovered.has_value()) {
|
|
const HoveredBlock &info = *hovered;
|
|
ImGui::BeginTooltip();
|
|
if (curve_count > 1) {
|
|
ImGui::Text("%s: %s (%d)", prepared_curves[static_cast<size_t>(info.curve_index)].label.c_str(),
|
|
info.block.label.c_str(), info.block.value);
|
|
} else {
|
|
ImGui::Text("%s (%d)", info.block.label.c_str(), info.block.value);
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::Text("%.3fs -> %.3fs", info.block.t0, info.block.t1);
|
|
ImGui::Text("duration: %.3fs", info.block.t1 - info.block.t0);
|
|
ImGui::EndTooltip();
|
|
}
|
|
}
|
|
|
|
void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state) {
|
|
if (tab == nullptr || !state.has_shared_range) {
|
|
return;
|
|
}
|
|
const double x_min = state.x_view_min;
|
|
const double x_max = state.x_view_max > state.x_view_min ? state.x_view_max : state.x_view_min + 1.0;
|
|
for (Pane &pane : tab->panes) {
|
|
pane.range.valid = true;
|
|
pane.range.left = x_min;
|
|
pane.range.right = x_max;
|
|
}
|
|
}
|
|
|
|
void clear_pane_vertical_limits(Pane *pane) {
|
|
if (pane == nullptr) {
|
|
return;
|
|
}
|
|
pane->range.has_y_limit_min = false;
|
|
pane->range.has_y_limit_max = false;
|
|
}
|
|
|
|
PlotBounds current_plot_bounds_for_pane(const AppSession &session, const Pane &pane, const UiState &state) {
|
|
std::vector<PreparedCurve> prepared_curves;
|
|
prepared_curves.reserve(pane.curves.size());
|
|
constexpr int kAxisEditorMaxPoints = 2048;
|
|
for (size_t curve_index = 0; curve_index < pane.curves.size(); ++curve_index) {
|
|
const Curve &curve = pane.curves[curve_index];
|
|
if (!curve.visible || !curve_has_samples(session, curve)) continue;
|
|
PreparedCurve prepared;
|
|
if (build_curve_series(session, curve, state, kAxisEditorMaxPoints, &prepared)) {
|
|
prepared.pane_curve_index = static_cast<int>(curve_index);
|
|
prepared_curves.push_back(std::move(prepared));
|
|
}
|
|
}
|
|
return compute_plot_bounds(pane, prepared_curves, state);
|
|
}
|
|
|
|
void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index) {
|
|
ensure_shared_range(state, session);
|
|
clamp_shared_range(state, session);
|
|
const WorkspaceTab *tab = app_active_tab(session.layout, *state);
|
|
if (tab == nullptr || pane_index < 0 || pane_index >= static_cast<int>(tab->panes.size())) {
|
|
return;
|
|
}
|
|
|
|
const Pane &pane = tab->panes[static_cast<size_t>(pane_index)];
|
|
const PlotBounds bounds = current_plot_bounds_for_pane(session, pane, *state);
|
|
AxisLimitsEditorState &editor = state->axis_limits;
|
|
editor.open = true;
|
|
editor.pane_index = pane_index;
|
|
editor.x_min = state->x_view_min;
|
|
editor.x_max = state->x_view_max;
|
|
editor.y_min_enabled = pane.range.has_y_limit_min;
|
|
editor.y_max_enabled = pane.range.has_y_limit_max;
|
|
editor.y_min = pane.range.has_y_limit_min ? pane.range.y_limit_min : bounds.y_min;
|
|
editor.y_max = pane.range.has_y_limit_max ? pane.range.y_limit_max : bounds.y_max;
|
|
}
|
|
|
|
bool apply_axis_limits_editor(AppSession *session, UiState *state) {
|
|
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
|
|
if (tab == nullptr) return false;
|
|
|
|
AxisLimitsEditorState &editor = state->axis_limits;
|
|
if (editor.pane_index < 0 || editor.pane_index >= static_cast<int>(tab->panes.size())) {
|
|
state->error_text = "The selected pane is no longer available.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
if (!std::isfinite(editor.x_min) || !std::isfinite(editor.x_max)) {
|
|
state->error_text = "Axis limits must be finite numbers.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
if (editor.x_max <= editor.x_min) {
|
|
state->error_text = "X max must be greater than X min.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
if (editor.y_min_enabled && !std::isfinite(editor.y_min)) {
|
|
state->error_text = "Y min must be a finite number.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
if (editor.y_max_enabled && !std::isfinite(editor.y_max)) {
|
|
state->error_text = "Y max must be a finite number.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
if (editor.y_min_enabled && editor.y_max_enabled && editor.y_max <= editor.y_min) {
|
|
state->error_text = "Y max must be greater than Y min.";
|
|
state->open_error_popup = true;
|
|
return false;
|
|
}
|
|
|
|
const SketchLayout before_layout = session->layout;
|
|
state->has_shared_range = true;
|
|
state->x_view_min = editor.x_min;
|
|
state->x_view_max = editor.x_max;
|
|
if (session->data_mode == SessionDataMode::Stream) {
|
|
state->follow_latest = infer_stream_follow_state(*state, *session);
|
|
} else {
|
|
state->follow_latest = false;
|
|
}
|
|
state->suppress_range_side_effects = true;
|
|
clamp_shared_range(state, *session);
|
|
persist_shared_range_to_tab(tab, *state);
|
|
|
|
Pane &pane = tab->panes[static_cast<size_t>(editor.pane_index)];
|
|
pane.range.has_y_limit_min = editor.y_min_enabled;
|
|
pane.range.has_y_limit_max = editor.y_max_enabled;
|
|
if (editor.y_min_enabled) {
|
|
pane.range.y_limit_min = editor.y_min;
|
|
}
|
|
if (editor.y_max_enabled) {
|
|
pane.range.y_limit_max = editor.y_max;
|
|
}
|
|
|
|
const PlotBounds bounds = current_plot_bounds_for_pane(*session, pane, *state);
|
|
pane.range.valid = true;
|
|
pane.range.left = state->x_view_min;
|
|
pane.range.right = state->x_view_max;
|
|
pane.range.bottom = bounds.y_min;
|
|
pane.range.top = bounds.y_max;
|
|
|
|
state->undo.push(before_layout);
|
|
const bool ok = mark_layout_dirty(session, state);
|
|
if (ok) {
|
|
state->status_text = "Axis limits updated";
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
void draw_plot(const AppSession &session, Pane *pane, UiState *state) {
|
|
std::vector<PreparedCurve> prepared_curves;
|
|
prepared_curves.reserve(pane->curves.size());
|
|
const int max_points = std::max(256, static_cast<int>(ImGui::GetContentRegionAvail().x) * 2);
|
|
for (size_t curve_index = 0; curve_index < pane->curves.size(); ++curve_index) {
|
|
const Curve &curve = pane->curves[curve_index];
|
|
if (!curve.visible || !curve_has_samples(session, curve)) continue;
|
|
PreparedCurve prepared;
|
|
if (build_curve_series(session, curve, *state, max_points, &prepared)) {
|
|
prepared.pane_curve_index = static_cast<int>(curve_index);
|
|
prepared_curves.push_back(std::move(prepared));
|
|
}
|
|
}
|
|
|
|
const PlotBounds bounds = compute_plot_bounds(*pane, prepared_curves, *state);
|
|
PaneValueFormatContext pane_value_format;
|
|
bool state_block_mode = !prepared_curves.empty();
|
|
size_t max_legend_label_width = 0;
|
|
for (const PreparedCurve &curve : prepared_curves) {
|
|
max_legend_label_width = std::max(max_legend_label_width, curve.label.size());
|
|
if (curve.enum_info == nullptr) {
|
|
state_block_mode = false;
|
|
merge_pane_value_format(&pane_value_format, curve.display_info);
|
|
}
|
|
}
|
|
const int supported_count = static_cast<int>(prepared_curves.size());
|
|
const ImVec2 plot_size = ImGui::GetContentRegionAvail();
|
|
const bool has_cursor_time = state->has_tracker_time;
|
|
const double cursor_time = state->tracker_time;
|
|
|
|
ImPlot::PushStyleColor(ImPlotCol_PlotBg, color_rgb(255, 255, 255));
|
|
ImPlot::PushStyleColor(ImPlotCol_PlotBorder, color_rgb(186, 190, 196));
|
|
ImPlot::PushStyleColor(ImPlotCol_LegendBg, color_rgb(248, 249, 251, 0.92f));
|
|
ImPlot::PushStyleColor(ImPlotCol_LegendBorder, color_rgb(168, 175, 184));
|
|
ImPlot::PushStyleColor(ImPlotCol_LegendText, color_rgb(57, 62, 69));
|
|
ImPlot::PushStyleColor(ImPlotCol_TitleText, color_rgb(57, 62, 69));
|
|
ImPlot::PushStyleColor(ImPlotCol_InlayText, color_rgb(95, 103, 112));
|
|
ImPlot::PushStyleColor(ImPlotCol_AxisGrid, color_rgb(188, 196, 206));
|
|
ImPlot::PushStyleColor(ImPlotCol_AxisText, color_rgb(95, 103, 112));
|
|
ImPlot::PushStyleColor(ImPlotCol_AxisBg, color_rgb(255, 255, 255, 0.0f));
|
|
ImPlot::PushStyleColor(ImPlotCol_AxisBgHovered, color_rgb(214, 220, 228, 0.45f));
|
|
ImPlot::PushStyleColor(ImPlotCol_AxisBgActive, color_rgb(199, 209, 222, 0.55f));
|
|
ImPlot::PushStyleColor(ImPlotCol_Selection, color_rgb(252, 211, 77, 0.28f));
|
|
ImPlot::PushStyleColor(ImPlotCol_Crosshairs, color_rgb(120, 128, 138, 0.70f));
|
|
ImPlot::PushStyleVar(ImPlotStyleVar_LegendPadding, ImVec2(56.0f, 10.0f));
|
|
|
|
ImPlotFlags plot_flags = ImPlotFlags_NoTitle | ImPlotFlags_NoMenus;
|
|
if (state_block_mode) {
|
|
plot_flags |= ImPlotFlags_NoLegend | ImPlotFlags_NoMouseText;
|
|
}
|
|
if (supported_count == 0) {
|
|
plot_flags |= ImPlotFlags_NoLegend;
|
|
}
|
|
|
|
const ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight;
|
|
ImPlotAxisFlags y_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight;
|
|
if (state_block_mode) {
|
|
y_axis_flags |= ImPlotAxisFlags_NoDecorations;
|
|
}
|
|
const bool explicit_y = pane->range.has_y_limit_min || pane->range.has_y_limit_max;
|
|
if (!state_block_mode && !explicit_y && supported_count > 0) {
|
|
y_axis_flags |= ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit;
|
|
}
|
|
|
|
const double previous_x_min = state->x_view_min;
|
|
const double previous_x_max = state->x_view_max;
|
|
app_push_mono_font();
|
|
if (ImPlot::BeginPlot("##plot", plot_size, plot_flags)) {
|
|
ImPlot::SetupAxes(nullptr, nullptr, x_axis_flags, y_axis_flags);
|
|
ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f");
|
|
if (state_block_mode) {
|
|
ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Always);
|
|
} else if (pane_value_format.valid) {
|
|
ImPlot::SetupAxisFormat(ImAxis_Y1, format_numeric_axis_tick, &pane_value_format);
|
|
} else {
|
|
ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g");
|
|
}
|
|
ImPlot::SetupAxisLinks(ImAxis_X1, &state->x_view_min, &state->x_view_max);
|
|
if (state->route_x_max > state->route_x_min) {
|
|
const double x_constraint_min = session.data_mode == SessionDataMode::Stream
|
|
? state->route_x_min - std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds)
|
|
: state->route_x_min;
|
|
ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, x_constraint_min, state->route_x_max);
|
|
}
|
|
if (!state_block_mode) {
|
|
ImPlot::SetupMouseText(ImPlotLocation_SouthEast, ImPlotMouseTextFlags_NoAuxAxes);
|
|
}
|
|
if (!state_block_mode && (explicit_y || supported_count == 0)) {
|
|
ImPlot::SetupAxisLimits(ImAxis_Y1, bounds.y_min, bounds.y_max, ImPlotCond_Always);
|
|
}
|
|
if (!state_block_mode && supported_count > 0) {
|
|
ImPlot::SetupLegend(ImPlotLocation_NorthEast);
|
|
}
|
|
|
|
if (state_block_mode) {
|
|
draw_state_blocks_pane(prepared_curves, state);
|
|
} else {
|
|
for (size_t i = 0; i < prepared_curves.size(); ++i) {
|
|
const PreparedCurve &curve = prepared_curves[i];
|
|
std::string series_id = curve_legend_label(curve, has_cursor_time, max_legend_label_width) + "###curve" + std::to_string(curve.pane_curve_index);
|
|
ImPlotSpec spec;
|
|
spec.LineColor = color_rgb(curve.color);
|
|
spec.LineWeight = curve.line_weight;
|
|
spec.Flags = ImPlotLineFlags_SkipNaN;
|
|
if (!curve.xs.empty() && curve.xs.size() == curve.ys.size()) {
|
|
if (curve.stairs) {
|
|
spec.Flags = ImPlotStairsFlags_PreStep;
|
|
ImPlot::PlotStairs(series_id.c_str(), curve.xs.data(), curve.ys.data(), static_cast<int>(curve.xs.size()), spec);
|
|
} else {
|
|
ImPlot::PlotLine(series_id.c_str(), curve.xs.data(), curve.ys.data(), static_cast<int>(curve.xs.size()), spec);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (has_cursor_time) {
|
|
const double clamped_cursor_time = std::clamp(cursor_time, state->route_x_min, state->route_x_max);
|
|
ImPlotSpec cursor_spec;
|
|
cursor_spec.LineColor = color_rgb(108, 118, 128, 0.7f);
|
|
cursor_spec.LineWeight = 1.0f;
|
|
cursor_spec.Flags = ImPlotItemFlags_NoLegend;
|
|
ImPlot::PlotInfLines("##tracker_cursor", &clamped_cursor_time, 1, cursor_spec);
|
|
}
|
|
if (ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
|
state->tracker_time = std::clamp(ImPlot::GetPlotMousePos().x, state->route_x_min, state->route_x_max);
|
|
state->has_tracker_time = true;
|
|
}
|
|
ImPlot::EndPlot();
|
|
}
|
|
app_pop_mono_font();
|
|
clamp_shared_range(state, session);
|
|
if (std::abs(state->x_view_min - previous_x_min) > 1.0e-6
|
|
|| std::abs(state->x_view_max - previous_x_max) > 1.0e-6) {
|
|
if (!state->suppress_range_side_effects) {
|
|
if (session.data_mode == SessionDataMode::Stream) {
|
|
state->follow_latest = infer_stream_follow_state(*state, session);
|
|
} else {
|
|
state->follow_latest = false;
|
|
}
|
|
}
|
|
}
|
|
ImPlot::PopStyleVar();
|
|
ImPlot::PopStyleColor(12);
|
|
}
|
|
|
|
std::optional<PaneMenuAction> draw_pane_context_menu(const WorkspaceTab &tab, int pane_index) {
|
|
if (!ImGui::BeginPopupContextWindow("##pane_context")) return std::nullopt;
|
|
|
|
PaneMenuAction action;
|
|
action.pane_index = pane_index;
|
|
const Pane *pane = pane_index >= 0 && pane_index < static_cast<int>(tab.panes.size())
|
|
? &tab.panes[static_cast<size_t>(pane_index)]
|
|
: nullptr;
|
|
const bool has_curves = pane_index >= 0
|
|
&& pane_index < static_cast<int>(tab.panes.size())
|
|
&& !tab.panes[static_cast<size_t>(pane_index)].curves.empty();
|
|
const bool is_plot = pane != nullptr && pane->kind == PaneKind::Plot;
|
|
if (icon_menu_item(icon::SLIDERS, "Edit Axis Limits...", nullptr, false, is_plot)) {
|
|
action.kind = PaneMenuActionKind::OpenAxisLimits;
|
|
}
|
|
icon_menu_item(icon::PALETTE, "Edit Curve Style...", nullptr, false, false && is_plot);
|
|
if (action.kind == PaneMenuActionKind::None
|
|
&& icon_menu_item(icon::PLUS_SLASH_MINUS, "Apply filter to data...", nullptr, false, has_curves && is_plot)) {
|
|
action.kind = PaneMenuActionKind::OpenCustomSeries;
|
|
}
|
|
ImGui::Separator();
|
|
if (action.kind == PaneMenuActionKind::None && icon_menu_item(icon::DISTRIBUTE_HORIZONTAL, "Split Left / Right")) {
|
|
action.kind = PaneMenuActionKind::SplitRight;
|
|
} else if (action.kind == PaneMenuActionKind::None
|
|
&& icon_menu_item(icon::DISTRIBUTE_VERTICAL, "Split Top / Bottom")) {
|
|
action.kind = PaneMenuActionKind::SplitBottom;
|
|
}
|
|
ImGui::Separator();
|
|
if (icon_menu_item(icon::ZOOM_OUT, "Zoom Out", nullptr, false, is_plot)) {
|
|
action.kind = PaneMenuActionKind::ResetView;
|
|
}
|
|
ImGui::Separator();
|
|
if (icon_menu_item(icon::TRASH, "Remove ALL curves", nullptr, false, is_plot)) {
|
|
action.kind = PaneMenuActionKind::Clear;
|
|
}
|
|
ImGui::Separator();
|
|
icon_menu_item(icon::FILE_EARMARK_IMAGE, "Copy image to clipboard", nullptr, false, false);
|
|
icon_menu_item(icon::SAVE, "Save plot to file", nullptr, false, false);
|
|
icon_menu_item(icon::BAR_CHART, "Show data statistics", nullptr, false, false);
|
|
ImGui::EndPopup();
|
|
if (action.kind == PaneMenuActionKind::None) return std::nullopt;
|
|
return action;
|
|
}
|