#include "tools/jotpluggler/internal.h" #include "implot.h" #include "imgui_internal.h" #include #include #include 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 &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 color = {160, 170, 180}; float line_weight = 2.0f; bool stairs = false; const EnumInfo *enum_info = nullptr; SeriesFormat display_info; std::optional legend_value; std::vector xs; std::vector 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 &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, 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(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(value) < curve.enum_info->names.size()) { const std::string &name = curve.enum_info->names[static_cast(value)]; if (!name.empty()) { return name; } } return std::to_string(value); } std::vector build_state_blocks(const PreparedCurve &curve) { std::vector blocks; if (curve.xs.size() < 2 || curve.xs.size() != curve.ys.size()) { return blocks; } int current_value = static_cast(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(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 &xs_in, const std::vector &ys_in, int max_points, std::vector *xs_out, std::vector *ys_out) { const size_t bucket_count = std::max(1, static_cast(max_points / 4)); const size_t bucket_size = std::max( 1, static_cast(std::ceil(static_cast(xs_in.size()) / static_cast(bucket_count)))); xs_out->reserve(bucket_count * 4 + 2); ys_out->reserve(bucket_count * 4 + 2); size_t last_index = std::numeric_limits::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 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 &xs_in, const std::vector &ys_in, int max_points, std::vector *xs_out, std::vector *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(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 &&xs_in, std::vector &&ys_in, int max_points, std::vector *xs_out, std::vector *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(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 app_sample_xy_value_at_time(const std::vector &xs, const std::vector &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(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(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 xs; std::vector 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(std::distance(series->times.begin(), begin_it - 1)); end_index = end_it == series->times.end() ? series->times.size() : static_cast(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 transformed_xs; std::vector 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 &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 &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(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(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 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(curve_index)]; const float y0 = plot_min.y + row_height * static_cast(curve_index); const float y1 = y0 + row_height; const std::vector 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((visible_t0 - state->x_view_min) / x_span) * blocks_width; const float x1 = blocks_min_x + static_cast((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((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(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 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(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(tab->panes.size())) { return; } const Pane &pane = tab->panes[static_cast(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(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(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 prepared_curves; prepared_curves.reserve(pane->curves.size()); const int max_points = std::max(256, static_cast(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(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(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(curve.xs.size()), spec); } else { ImPlot::PlotLine(series_id.c_str(), curve.xs.data(), curve.ys.data(), static_cast(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 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(tab.panes.size()) ? &tab.panes[static_cast(pane_index)] : nullptr; const bool has_curves = pane_index >= 0 && pane_index < static_cast(tab.panes.size()) && !tab.panes[static_cast(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; }