From b706673e1cf3ed61a928e6eb68bf8d663904a5de Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 25 Mar 2026 19:49:38 -0700 Subject: [PATCH] jotpluggler: part one (#37730) --- SConstruct | 3 +- tools/jotpluggler/.gitignore | 9 + tools/jotpluggler/SConscript | 92 + tools/jotpluggler/app.cc | 1914 ++++++++++++++ tools/jotpluggler/app.h | 884 +++++++ tools/jotpluggler/browser.cc | 465 ++++ tools/jotpluggler/camera.cc | 54 + tools/jotpluggler/camera.h | 5 + tools/jotpluggler/common.cc | 179 ++ tools/jotpluggler/common.h | 63 + tools/jotpluggler/custom_series.cc | 750 ++++++ tools/jotpluggler/dbc.h | 400 +++ tools/jotpluggler/generated_dbcs/.gitignore | 2 + tools/jotpluggler/icons.cc | 24 + tools/jotpluggler/internal.h | 166 ++ tools/jotpluggler/layout.cc | 704 ++++++ tools/jotpluggler/layout_io.cc | 128 + tools/jotpluggler/layouts/.gitignore | 1 + tools/jotpluggler/layouts/CAN-bus-debug.json | 1 + tools/jotpluggler/layouts/camera-timings.json | 1 + .../jotpluggler/layouts/cameras-and-map.json | 1 + tools/jotpluggler/layouts/can-states.json | 1 + .../layouts/controls_mismatch_debug.json | 1 + tools/jotpluggler/layouts/gps.json | 1 + tools/jotpluggler/layouts/gps_vs_llk.json | 1 + .../jotpluggler/layouts/locationd_debug.json | 1 + tools/jotpluggler/layouts/longitudinal.json | 1 + .../jotpluggler/layouts/max-torque-debug.json | 1 + tools/jotpluggler/layouts/new-layout.json | 1 + .../jotpluggler/layouts/system_lag_debug.json | 1 + tools/jotpluggler/layouts/thermal_debug.json | 1 + .../layouts/torque-controller.json | 1 + tools/jotpluggler/layouts/tuning.json | 1 + tools/jotpluggler/layouts/ublox-debug.json | 1 + tools/jotpluggler/logs.cc | 419 ++++ tools/jotpluggler/main.cc | 126 + tools/jotpluggler/map.cc | 1328 ++++++++++ tools/jotpluggler/map.h | 61 + tools/jotpluggler/math_eval.py | 145 ++ tools/jotpluggler/plot.cc | 1027 ++++++++ tools/jotpluggler/render.cc | 173 ++ tools/jotpluggler/runtime.cc | 1280 ++++++++++ tools/jotpluggler/session.cc | 773 ++++++ tools/jotpluggler/sidebar.cc | 215 ++ tools/jotpluggler/sketch_layout.cc | 2202 +++++++++++++++++ tools/jotpluggler/stream.cc | 207 ++ tools/jotpluggler/util.cc | 59 + tools/jotpluggler/util.h | 103 + tools/replay/logreader.cc | 58 +- tools/replay/logreader.h | 25 +- tools/replay/py_downloader.cc | 9 +- 51 files changed, 14061 insertions(+), 8 deletions(-) create mode 100644 tools/jotpluggler/.gitignore create mode 100644 tools/jotpluggler/SConscript create mode 100644 tools/jotpluggler/app.cc create mode 100644 tools/jotpluggler/app.h create mode 100644 tools/jotpluggler/browser.cc create mode 100644 tools/jotpluggler/camera.cc create mode 100644 tools/jotpluggler/camera.h create mode 100644 tools/jotpluggler/common.cc create mode 100644 tools/jotpluggler/common.h create mode 100644 tools/jotpluggler/custom_series.cc create mode 100644 tools/jotpluggler/dbc.h create mode 100644 tools/jotpluggler/generated_dbcs/.gitignore create mode 100644 tools/jotpluggler/icons.cc create mode 100644 tools/jotpluggler/internal.h create mode 100644 tools/jotpluggler/layout.cc create mode 100644 tools/jotpluggler/layout_io.cc create mode 100644 tools/jotpluggler/layouts/.gitignore create mode 100644 tools/jotpluggler/layouts/CAN-bus-debug.json create mode 100644 tools/jotpluggler/layouts/camera-timings.json create mode 100644 tools/jotpluggler/layouts/cameras-and-map.json create mode 100644 tools/jotpluggler/layouts/can-states.json create mode 100644 tools/jotpluggler/layouts/controls_mismatch_debug.json create mode 100644 tools/jotpluggler/layouts/gps.json create mode 100644 tools/jotpluggler/layouts/gps_vs_llk.json create mode 100644 tools/jotpluggler/layouts/locationd_debug.json create mode 100644 tools/jotpluggler/layouts/longitudinal.json create mode 100644 tools/jotpluggler/layouts/max-torque-debug.json create mode 100644 tools/jotpluggler/layouts/new-layout.json create mode 100644 tools/jotpluggler/layouts/system_lag_debug.json create mode 100644 tools/jotpluggler/layouts/thermal_debug.json create mode 100644 tools/jotpluggler/layouts/torque-controller.json create mode 100644 tools/jotpluggler/layouts/tuning.json create mode 100644 tools/jotpluggler/layouts/ublox-debug.json create mode 100644 tools/jotpluggler/logs.cc create mode 100644 tools/jotpluggler/main.cc create mode 100644 tools/jotpluggler/map.cc create mode 100644 tools/jotpluggler/map.h create mode 100755 tools/jotpluggler/math_eval.py create mode 100644 tools/jotpluggler/plot.cc create mode 100644 tools/jotpluggler/render.cc create mode 100644 tools/jotpluggler/runtime.cc create mode 100644 tools/jotpluggler/session.cc create mode 100644 tools/jotpluggler/sidebar.cc create mode 100644 tools/jotpluggler/sketch_layout.cc create mode 100644 tools/jotpluggler/stream.cc create mode 100644 tools/jotpluggler/util.cc create mode 100644 tools/jotpluggler/util.h diff --git a/SConstruct b/SConstruct index 792a48eb7..119209dcd 100644 --- a/SConstruct +++ b/SConstruct @@ -49,7 +49,7 @@ pkgs = [importlib.import_module(name) for name in pkg_names] allowed_system_libs = { "EGL", "GLESv2", "GL", "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", - "dl", "drm", "gbm", "m", "pthread", + "dl", "drm", "gbm", "m", "pthread", } def _resolve_lib(env, name): @@ -259,6 +259,7 @@ if arch != "larch64": SConscript([ 'tools/replay/SConscript', 'tools/cabana/SConscript', + 'tools/jotpluggler/SConscript', ]) diff --git a/tools/jotpluggler/.gitignore b/tools/jotpluggler/.gitignore new file mode 100644 index 000000000..7cb98300f --- /dev/null +++ b/tools/jotpluggler/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +jot_*.o +*.o +jotpluggler +car_fingerprint_to_dbc.h +generated_dbcs/.stamp +generated_dbcs/*.dbc +layouts/.jotpluggler_autosave/ +reports/ diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript new file mode 100644 index 000000000..122d50234 --- /dev/null +++ b/tools/jotpluggler/SConscript @@ -0,0 +1,92 @@ +import os +import imgui +import libusb +from opendbc import get_generated_dbcs +from opendbc.car import Bus +from opendbc.car.fingerprints import MIGRATION +from opendbc.car.values import PLATFORMS +from openpilot.common.basedir import BASEDIR + +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib') + +jot_env = env.Clone() +jot_env["LIBPATH"] += [imgui.MESA_DIR, libusb.LIB_DIR] +jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +jot_env["CXXFLAGS"] += [ + "-DGLFW_INCLUDE_NONE", + '-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR), +] + +def materialize_generated_dbcs(target, source, env): + out_dir = os.path.dirname(str(target[0])) + os.makedirs(out_dir, exist_ok=True) + + for name in os.listdir(out_dir): + if name.endswith('.dbc'): + os.unlink(os.path.join(out_dir, name)) + + for name, content in sorted(get_generated_dbcs().items()): + with open(os.path.join(out_dir, f"{name}.dbc"), "w") as f: + f.write(content) + + with open(str(target[0]), "w") as f: + f.write("ok\n") + + return None + +def write_car_fingerprint_to_dbc_header(target, source, env): + pairs = {} + + for name, platform in sorted(PLATFORMS.items()): + dbc = platform.config.dbc_dict.get(Bus.pt, "") + if not dbc and name.startswith("TESLA_"): + dbc = platform.config.dbc_dict.get(Bus.party, "") + if not dbc and name == "COMMA_BODY": + dbc = "comma_body" + if dbc and name != "MOCK": + pairs[name] = dbc + + for fingerprint, car in sorted(MIGRATION.items()): + dbc = pairs.get(str(car), "") + if dbc: + pairs[fingerprint] = dbc + + lines = [ + "#pragma once", + "", + "#include ", + "#include ", + "", + "inline constexpr std::pair kCarFingerprintToDbc[] = {", + ] + lines.extend(f' {{"{fingerprint}", "{dbc}"}},' for fingerprint, dbc in sorted(pairs.items())) + lines.extend([ + "};", + "", + "inline std::string_view dbc_for_car_fingerprint(std::string_view fingerprint) {", + " for (const auto &[car_fingerprint, dbc] : kCarFingerprintToDbc) {", + " if (car_fingerprint == fingerprint) return dbc;", + " }", + " return {};", + "}", + "", + ]) + + with open(str(target[0]), "w") as f: + f.write("\n".join(lines)) + + return None + +generated_dbc_stamp = jot_env.Command(f"generated_dbcs/.stamp", [], materialize_generated_dbcs) +car_fingerprint_to_dbc = jot_env.Command("car_fingerprint_to_dbc.h", [], write_car_fingerprint_to_dbc_header) + +libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"), + "avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "m", "pthread", "usb-1.0"] +if arch == "Darwin": + jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] +else: + libs += ["GL", "dl", "va", "va-drm", "drm"] + +program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs) +jot_env.Depends(program, generated_dbc_stamp) +jot_env.Depends(program, car_fingerprint_to_dbc) diff --git a/tools/jotpluggler/app.cc b/tools/jotpluggler/app.cc new file mode 100644 index 000000000..4b56299ea --- /dev/null +++ b/tools/jotpluggler/app.cc @@ -0,0 +1,1914 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/camera.h" +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/internal.h" +#include "tools/jotpluggler/map.h" +#include "system/hardware/hw.h" +#include "imgui_impl_glfw.h" + +#include "imgui_internal.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +constexpr const char *UNTITLED_PANE_TITLE = "..."; +ImFont *g_ui_font = nullptr; +ImFont *g_ui_bold_font = nullptr; +ImFont *g_mono_font = nullptr; + +std::string layout_name_from_arg(const std::string &layout_arg) { + const fs::path raw(layout_arg); + if (raw.extension() == ".xml" || raw.extension() == ".json") { + return raw.stem().string(); + } + if (raw.filename() != raw) { + return raw.filename().replace_extension("").string(); + } + fs::path stem_path = raw; + return stem_path.replace_extension("").string(); +} + +fs::path layouts_dir() { + return repo_root() / "tools" / "jotpluggler" / "layouts"; +} + +std::string sanitize_layout_stem(std::string_view name) { + std::string out; + out.reserve(name.size()); + bool last_was_dash = false; + for (const char raw : name) { + const unsigned char c = static_cast(raw); + if (std::isalnum(c) != 0) { + out.push_back(static_cast(std::tolower(c))); + last_was_dash = false; + } else if (raw == '-' || raw == '_') { + out.push_back(raw); + last_was_dash = false; + } else if (!last_was_dash && !out.empty()) { + out.push_back('-'); + last_was_dash = true; + } + } + while (!out.empty() && out.back() == '-') { + out.pop_back(); + } + return out.empty() ? "untitled" : out; +} + +fs::path autosave_dir() { + return layouts_dir() / ".jotpluggler_autosave"; +} + +fs::path resolve_layout_path(const std::string &layout_arg) { + const fs::path direct(layout_arg); + if (fs::exists(direct)) { + if (direct.extension() == ".json") return fs::absolute(direct); + const fs::path sibling_json = direct.parent_path() / (direct.stem().string() + ".json"); + if (direct.extension() == ".xml" && fs::exists(sibling_json)) { + return fs::absolute(sibling_json); + } + } + const fs::path candidate = layouts_dir() / (layout_name_from_arg(layout_arg) + ".json"); + if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg); + return candidate; +} + +fs::path autosave_path_for_layout(const fs::path &layout_path) { + const std::string stem = layout_path.empty() ? "untitled" : layout_path.stem().string(); + return autosave_dir() / (sanitize_layout_stem(stem) + ".json"); +} + +std::vector available_layout_names() { + std::vector names; + const fs::path root = layouts_dir(); + if (!fs::exists(root) || !fs::is_directory(root)) { + return names; + } + for (const auto &entry : fs::directory_iterator(root)) { + if (!entry.is_regular_file() || entry.path().extension() != ".json") { + continue; + } + names.push_back(entry.path().stem().string()); + } + std::sort(names.begin(), names.end()); + return names; +} + +void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks) { + state->tabs.clear(); + cancel_rename_tab(state); + sync_ui_state(state, session->layout); + sync_layout_buffers(state, *session); + if (mark_docks) { + mark_all_docks_dirty(state); + } +} + +void start_new_layout(AppSession *session, UiState *state, const std::string &status_text) { + session->layout = make_empty_layout(); + session->layout_path.clear(); + session->autosave_path.clear(); + state->undo.reset(session->layout); + state->layout_dirty = false; + state->status_text = status_text; + refresh_replaced_layout_ui(session, state, true); + reset_shared_range(state, *session); +} + +bool is_decoded_can_series_path(std::string_view path) { + const std::string value(path); + return util::starts_with(value, "/can/") || util::starts_with(value, "/sendcan/"); +} + +bool apply_route_can_decode_update(AppSession *session, UiState *state); + +void rebuild_series_lookup_preserving_formats(AppSession *session, + std::string_view updated_prefix, + bool refresh_updated_formats_only) { + const std::string prefix(updated_prefix); + if (!updated_prefix.empty()) { + for (auto it = session->route_data.series_formats.begin(); it != session->route_data.series_formats.end();) { + if (util::starts_with(it->first, prefix)) { + it = session->route_data.series_formats.erase(it); + } else { + ++it; + } + } + } + session->series_by_path.clear(); + session->series_by_path.reserve(session->route_data.series.size()); + for (RouteSeries &series : session->route_data.series) { + session->series_by_path.emplace(series.path, &series); + if (refresh_updated_formats_only) { + if (!updated_prefix.empty() && util::starts_with(series.path, prefix)) { + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); + } + } else { + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); + } + } +} + +bool apply_route_can_decode_update(AppSession *session, UiState *state) { + const std::string active_dbc_name = !session->dbc_override.empty() ? session->dbc_override : session->route_data.dbc_name; + if (!active_dbc_name.empty() && !load_dbc_by_name(active_dbc_name).has_value()) { + state->error_text = "DBC not found: " + active_dbc_name; + state->open_error_popup = true; + return false; + } + std::unordered_map can_enum_info; + std::vector can_series = decode_can_messages(session->route_data.can_messages, active_dbc_name, &can_enum_info); + + std::vector updated_series; + updated_series.reserve(session->route_data.series.size() + can_series.size()); + for (RouteSeries &series : session->route_data.series) { + if (!is_decoded_can_series_path(series.path)) { + updated_series.push_back(std::move(series)); + } + } + for (RouteSeries &series : can_series) { + updated_series.push_back(std::move(series)); + } + std::sort(updated_series.begin(), updated_series.end(), [](const RouteSeries &a, const RouteSeries &b) { + return a.path < b.path; + }); + + std::unordered_map updated_enum_info; + updated_enum_info.reserve(session->route_data.enum_info.size() + can_enum_info.size()); + for (auto &[path, info] : session->route_data.enum_info) { + if (!is_decoded_can_series_path(path)) { + updated_enum_info.emplace(path, std::move(info)); + } + } + for (auto &[path, info] : can_enum_info) { + updated_enum_info[path] = std::move(info); + } + + session->route_data.series = std::move(updated_series); + session->route_data.enum_info = std::move(updated_enum_info); + session->route_data.paths.clear(); + session->route_data.paths.reserve(session->route_data.series.size()); + for (const RouteSeries &series : session->route_data.series) { + session->route_data.paths.push_back(series.path); + } + std::sort(session->route_data.paths.begin(), session->route_data.paths.end()); + session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); + + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + refresh_all_custom_curves(session, state); + sync_camera_feeds(session); + return true; +} + +void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override) { + session->dbc_override = dbc_override; + if (session->data_mode == SessionDataMode::Stream) { + start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, false); + } else if (!session->route_name.empty()) { + const bool ok = apply_route_can_decode_update(session, state); + if (ok) { + state->status_text = dbc_override.empty() ? "DBC auto-detect enabled" : "DBC set to " + dbc_override; + } else { + state->status_text = "Failed to apply DBC"; + } + } else if (dbc_override.empty()) { + state->status_text = "DBC auto-detect enabled"; + } else { + state->status_text = "DBC set to " + dbc_override; + } +} + +void configure_style() { + ImGui::StyleColorsLight(); + ImPlot::StyleColorsLight(); + + ImGuiIO &io = ImGui::GetIO(); + g_ui_font = nullptr; + g_ui_bold_font = nullptr; + g_mono_font = nullptr; + const fs::path fonts_dir = repo_root() / "selfdrive" / "assets" / "fonts"; + ImFontConfig font_cfg; + font_cfg.OversampleH = 2; + font_cfg.OversampleV = 2; + font_cfg.RasterizerDensity = 1.0f; + icon_add_font(16.0f); + const auto add_font_with_icons = [&](const fs::path &path, float size) -> ImFont * { + ImFont *font = io.Fonts->AddFontFromFileTTF(path.c_str(), size, &font_cfg); + if (font != nullptr) { + icon_add_font(size, true, font); + } + return font; + }; + if (ImFont *font = add_font_with_icons(fonts_dir / "Inter-Regular.ttf", 16.0f); font != nullptr) { + g_ui_font = font; + io.FontDefault = font; + } + g_ui_bold_font = add_font_with_icons(fonts_dir / "Inter-SemiBold.ttf", 16.75f); + if (g_ui_font == nullptr) { + if (ImFont *font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); font != nullptr) { + g_mono_font = font; + io.FontDefault = font; + } + } + if (g_mono_font == nullptr) { + g_mono_font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); + } + if (g_ui_bold_font == nullptr) { + g_ui_bold_font = g_ui_font; + } + + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.PopupRounding = 0.0f; + style.FrameRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.GrabRounding = 2.0f; + style.TabRounding = 0.0f; + style.WindowBorderSize = 1.0f; + style.ChildBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.WindowPadding = ImVec2(8.0f, 7.0f); + style.FramePadding = ImVec2(6.0f, 3.0f); + style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.ItemInnerSpacing = ImVec2(6.0f, 3.0f); + struct ColorDef { ImGuiCol idx; int r, g, b; }; + constexpr ColorDef COLORS[] = { + {ImGuiCol_WindowBg, 250, 250, 251}, {ImGuiCol_ChildBg, 255, 255, 255}, + {ImGuiCol_Border, 194, 198, 204}, {ImGuiCol_TitleBg, 252, 252, 253}, + {ImGuiCol_TitleBgActive, 252, 252, 253}, {ImGuiCol_TitleBgCollapsed, 252, 252, 253}, + {ImGuiCol_Text, 74, 80, 88}, {ImGuiCol_TextDisabled, 108, 118, 128}, + {ImGuiCol_Button, 255, 255, 255}, {ImGuiCol_ButtonHovered, 246, 248, 250}, + {ImGuiCol_ButtonActive, 238, 240, 244}, {ImGuiCol_FrameBg, 255, 255, 255}, + {ImGuiCol_FrameBgHovered, 248, 249, 251}, {ImGuiCol_FrameBgActive, 241, 244, 248}, + {ImGuiCol_Header, 243, 245, 248}, {ImGuiCol_HeaderHovered, 237, 240, 244}, + {ImGuiCol_HeaderActive, 232, 236, 240}, {ImGuiCol_PopupBg, 248, 249, 251}, + {ImGuiCol_MenuBarBg, 232, 236, 241}, {ImGuiCol_Separator, 194, 198, 204}, + {ImGuiCol_ScrollbarBg, 240, 242, 245}, {ImGuiCol_ScrollbarGrab, 202, 207, 214}, + {ImGuiCol_ScrollbarGrabHovered, 180, 186, 194}, {ImGuiCol_ScrollbarGrabActive, 164, 171, 180}, + {ImGuiCol_Tab, 219, 224, 230}, {ImGuiCol_TabHovered, 232, 236, 241}, + {ImGuiCol_TabSelected, 250, 251, 253}, {ImGuiCol_TabSelectedOverline, 92, 109, 136}, + {ImGuiCol_TabDimmed, 213, 219, 226}, {ImGuiCol_TabDimmedSelected, 244, 247, 249}, + {ImGuiCol_TabDimmedSelectedOverline, 92, 109, 136}, {ImGuiCol_DockingEmptyBg, 244, 246, 248}, + }; + for (const auto &c : COLORS) { style.Colors[c.idx] = color_rgb(c.r, c.g, c.b); } + style.Colors[ImGuiCol_DockingPreview] = color_rgb(69, 115, 184, 0.22f); + + ImPlotStyle &plot_style = ImPlot::GetStyle(); + plot_style.PlotBorderSize = 1.0f; + plot_style.MinorAlpha = 0.65f; + plot_style.LegendPadding = ImVec2(6.0f, 5.0f); + plot_style.LegendInnerPadding = ImVec2(6.0f, 3.0f); + plot_style.LegendSpacing = ImVec2(7.0f, 2.0f); + plot_style.PlotPadding = ImVec2(4.0f, 8.0f); + plot_style.FitPadding = ImVec2(0.02f, 0.4f); + + ImPlot::MapInputDefault(); + ImPlotInputMap &input_map = ImPlot::GetInputMap(); + input_map.Pan = ImGuiMouseButton_Right; + input_map.PanMod = ImGuiMod_None; + input_map.Select = ImGuiMouseButton_Left; + input_map.SelectCancel = ImGuiMouseButton_Right; + input_map.SelectMod = ImGuiMod_None; +} + +void app_push_mono_font() { + if (g_mono_font != nullptr) { + ImGui::PushFont(g_mono_font); + } +} + +void app_pop_mono_font() { + if (g_mono_font != nullptr) { + ImGui::PopFont(); + } +} + +void app_push_bold_font() { + if (g_ui_bold_font != nullptr) { + ImGui::PushFont(g_ui_bold_font); + } +} + +void app_pop_bold_font() { + if (g_ui_bold_font != nullptr) { + ImGui::PopFont(); + } +} + +UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width) { + UiMetrics ui; + ui.width = size.x; + ui.height = size.y; + ui.top_offset = top_offset; + ui.sidebar_width = sidebar_width <= 0.0f + ? 0.0f + : std::clamp(sidebar_width, SIDEBAR_MIN_WIDTH, std::min(SIDEBAR_MAX_WIDTH, size.x * 0.6f)); + ui.content_x = ui.sidebar_width; + ui.content_y = top_offset; + ui.content_w = std::max(1.0f, size.x - ui.content_x); + ui.content_h = std::max(1.0f, size.y - ui.content_y - STATUS_BAR_HEIGHT); + ui.status_bar_y = std::max(0.0f, size.y - STATUS_BAR_HEIGHT); + return ui; +} + +void sync_ui_state(UiState *state, const SketchLayout &layout) { + const bool initializing = state->tabs.empty(); + state->tabs.resize(layout.tabs.size()); + if (layout.tabs.empty()) { + state->active_tab_index = 0; + state->requested_tab_index = -1; + return; + } + if (initializing) { + state->active_tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); + state->requested_tab_index = state->active_tab_index; + } + state->active_tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout.tabs.size()) - 1); + for (size_t i = 0; i < layout.tabs.size(); ++i) { + if (state->tabs[i].runtime_id == 0) { + state->tabs[i].runtime_id = state->next_tab_runtime_id++; + } + const int pane_count = static_cast(layout.tabs[i].panes.size()); + state->tabs[i].map_panes.resize(static_cast(std::max(0, pane_count))); + state->tabs[i].camera_panes.resize(static_cast(std::max(0, pane_count))); + state->tabs[i].active_pane_index = pane_count <= 0 + ? 0 + : std::clamp(state->tabs[i].active_pane_index, 0, pane_count - 1); + } +} + +void resize_tab_pane_state(TabUiState *tab_state, size_t pane_count) { + if (tab_state == nullptr) return; + tab_state->map_panes.resize(pane_count); + tab_state->camera_panes.resize(pane_count); +} + +void sync_route_buffers(UiState *state, const AppSession &session) { + state->route_buffer = session.route_name; + state->data_dir_buffer = session.data_dir; +} + +void sync_stream_buffers(UiState *state, const AppSession &session) { + state->stream_address_buffer = session.stream_source.address; + state->stream_source_kind = session.stream_source.kind; + state->stream_buffer_seconds = session.stream_buffer_seconds; +} + +fs::path default_layout_save_path(const AppSession &session) { + return session.layout_path.empty() ? layouts_dir() / "new-layout.json" : session.layout_path; +} + +void sync_layout_buffers(UiState *state, const AppSession &session) { + state->load_layout_buffer = session.layout_path.empty() ? std::string() : session.layout_path.string(); + state->save_layout_buffer = default_layout_save_path(session).string(); +} + +const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state) { + if (layout.tabs.empty()) return nullptr; + const int index = std::clamp(state.active_tab_index, 0, static_cast(layout.tabs.size()) - 1); + return &layout.tabs[static_cast(index)]; +} + +WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state) { + if (layout->tabs.empty()) return nullptr; + const int index = std::clamp(state.active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + return &layout->tabs[static_cast(index)]; +} + +TabUiState *app_active_tab_state(UiState *state) { + if (state->tabs.empty()) return nullptr; + const int index = std::clamp(state->active_tab_index, 0, static_cast(state->tabs.size()) - 1); + return &state->tabs[static_cast(index)]; +} + +std::string pane_window_name(int tab_runtime_id, int pane_index, const Pane &pane) { + const char *title = pane.title.empty() ? UNTITLED_PANE_TITLE : pane.title.c_str(); + return util::string_format("%s###tab%d_pane%d", title, tab_runtime_id, pane_index); +} + +std::string tab_item_label(const WorkspaceTab &tab, int tab_runtime_id) { + return util::string_format("%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id); +} + +void request_tab_selection(UiState *state, int tab_index) { + state->active_tab_index = tab_index; + state->requested_tab_index = tab_index; +} + +void begin_rename_tab(const SketchLayout &layout, UiState *state, int tab_index) { + if (tab_index < 0 || tab_index >= static_cast(layout.tabs.size())) { + return; + } + state->rename_tab_buffer = layout.tabs[static_cast(tab_index)].tab_name; + state->rename_tab_index = tab_index; + state->focus_rename_tab_input = true; + request_tab_selection(state, tab_index); +} + +void cancel_rename_tab(UiState *state) { + state->rename_tab_index = -1; + state->focus_rename_tab_input = false; +} + +ImGuiID dockspace_id_for_tab(int tab_runtime_id) { + return ImHashStr(util::string_format("jotpluggler_dockspace_%d", tab_runtime_id).c_str()); +} + +bool curve_has_local_samples(const Curve &curve) { + return curve.xs.size() > 1 && curve.xs.size() == curve.ys.size(); +} + +void mark_all_docks_dirty(UiState *state) { + for (TabUiState &tab_state : state->tabs) { + tab_state.dock_needs_build = true; + } +} + +void mark_tab_dock_dirty(UiState *state, int tab_index) { + if (tab_index >= 0 && tab_index < static_cast(state->tabs.size())) { + state->tabs[static_cast(tab_index)].dock_needs_build = true; + } +} + +void normalize_split_node(WorkspaceNode *node) { + if (node->is_pane) { + return; + } + for (WorkspaceNode &child : node->children) { + normalize_split_node(&child); + } + if (node->children.empty()) { + return; + } + if (node->children.size() == 1) { + *node = node->children.front(); + return; + } + if (node->sizes.size() != node->children.size()) { + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return; + } + float total = 0.0f; + for (float &size : node->sizes) { + size = std::max(size, 0.0f); + total += size; + } + if (total <= 0.0f) { + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return; + } + for (float &size : node->sizes) { + size /= total; + } +} + +void decrement_pane_indices(WorkspaceNode *node, int removed_index) { + if (node->is_pane) { + if (node->pane_index > removed_index) { + node->pane_index -= 1; + } + return; + } + for (WorkspaceNode &child : node->children) { + decrement_pane_indices(&child, removed_index); + } +} + +bool remove_pane_node(WorkspaceNode *node, int pane_index) { + if (node->is_pane) return node->pane_index == pane_index; + + for (size_t i = 0; i < node->children.size();) { + if (remove_pane_node(&node->children[i], pane_index)) { + node->children.erase(node->children.begin() + static_cast(i)); + if (i < node->sizes.size()) { + node->sizes.erase(node->sizes.begin() + static_cast(i)); + } + } else { + ++i; + } + } + + normalize_split_node(node); + return !node->is_pane && node->children.empty(); +} + +bool split_pane_node(WorkspaceNode *node, int target_pane_index, SplitOrientation orientation, + bool new_before, int new_pane_index) { + if (node->is_pane) { + if (node->pane_index != target_pane_index) return false; + WorkspaceNode existing_pane; + existing_pane.is_pane = true; + existing_pane.pane_index = target_pane_index; + + WorkspaceNode new_pane; + new_pane.is_pane = true; + new_pane.pane_index = new_pane_index; + + node->is_pane = false; + node->pane_index = -1; + node->orientation = orientation; + node->sizes = {0.5f, 0.5f}; + node->children.clear(); + if (new_before) { + node->children.push_back(std::move(new_pane)); + node->children.push_back(std::move(existing_pane)); + } else { + node->children.push_back(std::move(existing_pane)); + node->children.push_back(std::move(new_pane)); + } + return true; + } + + if (node->orientation == orientation) { + for (size_t i = 0; i < node->children.size(); ++i) { + WorkspaceNode &child = node->children[i]; + if (!child.is_pane || child.pane_index != target_pane_index) { + continue; + } + + WorkspaceNode new_pane; + new_pane.is_pane = true; + new_pane.pane_index = new_pane_index; + + const auto insert_it = node->children.begin() + static_cast(new_before ? i : i + 1); + node->children.insert(insert_it, std::move(new_pane)); + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return true; + } + } + + for (WorkspaceNode &child : node->children) { + if (split_pane_node(&child, target_pane_index, orientation, new_before, new_pane_index)) return true; + } + return false; +} + +Pane make_empty_pane(const std::string &title = UNTITLED_PANE_TITLE) { + Pane pane; + pane.title = title; + return pane; +} + +WorkspaceTab make_empty_tab(const std::string &tab_name) { + WorkspaceTab tab; + tab.tab_name = tab_name; + tab.panes.push_back(make_empty_pane()); + tab.root.is_pane = true; + tab.root.pane_index = 0; + return tab; +} + +SketchLayout make_empty_layout() { + SketchLayout layout; + layout.tabs.push_back(make_empty_tab("tab1")); + layout.current_tab_index = 0; + layout.roots.push_back("layout"); + return layout; +} + +bool tab_name_exists(const SketchLayout &layout, const std::string &name) { + return std::any_of(layout.tabs.begin(), layout.tabs.end(), [&](const WorkspaceTab &tab) { + return tab.tab_name == name; + }); +} + +std::string next_tab_name(const SketchLayout &layout, const std::string &base_name) { + if (base_name == "tab" || base_name == "tab1") { + int max_suffix = 0; + for (const WorkspaceTab &tab : layout.tabs) { + if (tab.tab_name.size() > 3 && util::starts_with(tab.tab_name, "tab")) { + const std::string suffix = tab.tab_name.substr(3); + if (!suffix.empty() && std::all_of(suffix.begin(), suffix.end(), ::isdigit)) { + max_suffix = std::max(max_suffix, std::stoi(suffix)); + } + } + } + return "tab" + std::to_string(std::max(1, max_suffix + 1)); + } + std::string base = base_name.empty() ? "tab" : base_name; + if (!tab_name_exists(layout, base)) return base; + for (int i = 2; i < 1000; ++i) { + const std::string candidate = base + " " + std::to_string(i); + if (!tab_name_exists(layout, candidate)) return candidate; + } + return base + " copy"; +} + +void clear_layout_autosave(const AppSession &session) { + if (!session.autosave_path.empty() && fs::exists(session.autosave_path)) { + fs::remove(session.autosave_path); + } +} + +bool autosave_layout(AppSession *session, UiState *state) { + try { + if (session->autosave_path.empty()) { + session->autosave_path = autosave_path_for_layout(session->layout_path); + } + session->layout.current_tab_index = state->active_tab_index; + save_layout_json(session->layout, session->autosave_path); + state->layout_dirty = true; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to save layout draft"; + return false; + } +} + +bool mark_layout_dirty(AppSession *session, UiState *state) { + return autosave_layout(session, state); +} + +bool active_tab_has_map_pane(const SketchLayout &layout) { + if (layout.tabs.empty()) { + return false; + } + const int tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); + const WorkspaceTab &tab = layout.tabs[static_cast(tab_index)]; + return std::any_of(tab.panes.begin(), tab.panes.end(), [](const Pane &pane) { + return pane_kind_is_special(pane.kind); + }); +} + +void draw_browser_special_item(const char *item_id, const char *label) { + const ImGuiStyle &style = ImGui::GetStyle(); + const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight()); + ImGui::PushID(item_id); + ImGui::InvisibleButton("##special_data_row", row_size); + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + if (hovered) { + const ImU32 bg = ImGui::GetColorU32(held ? ImGuiCol_HeaderActive : ImGuiCol_HeaderHovered); + draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f); + } + ImGui::RenderTextEllipsis(draw_list, + ImVec2(rect.Min.x + style.FramePadding.x, rect.Min.y + style.FramePadding.y), + ImVec2(rect.Max.x - style.FramePadding.x, rect.Max.y), + rect.Max.x - style.FramePadding.x, + label, + nullptr, + nullptr); + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("JOTP_SPECIAL_ITEM", item_id, std::strlen(item_id) + 1); + ImGui::TextUnformatted(label); + ImGui::EndDragDropSource(); + } + ImGui::PopID(); +} + +std::array app_next_curve_color(const Pane &pane) { + static constexpr std::array, 10> PALETTE = {{ + {35, 107, 180}, + {220, 82, 52}, + {67, 160, 71}, + {243, 156, 18}, + {123, 97, 255}, + {0, 150, 136}, + {214, 48, 49}, + {52, 73, 94}, + {197, 90, 17}, + {96, 125, 139}, + }}; + return PALETTE[pane.curves.size() % PALETTE.size()]; +} + +void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed) { + ImGui::SetNextWindowPos(ImVec2(0.0f, ui.top_offset)); + ImGui::SetNextWindowSize(ImVec2(ui.sidebar_width, std::max(1.0f, ui.height - ui.top_offset))); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(238, 240, 244)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(190, 197, 205)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings; + if (ImGui::Begin("##sidebar", nullptr, flags)) { + const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + const bool show_load_progress = session->route_loader && (load.active || load.total_segments > 0); + const bool streaming = session->data_mode == SessionDataMode::Stream; + CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); + if (show_camera_feed && sidebar_camera != nullptr) { + sidebar_camera->draw(ImGui::GetContentRegionAvail().x, load.active); + } else if (streaming) { + ImGui::SeparatorText("Camera"); + ImGui::TextDisabled("Camera not available during live stream."); + ImGui::Spacing(); + } + + ImGui::SeparatorText(streaming ? "Stream" : "Route"); + if (streaming) { + const StreamPollSnapshot stream = session->stream_poller ? session->stream_poller->snapshot() : StreamPollSnapshot{}; + const bool paused = stream.paused || session->stream_paused; + const bool live = stream.connected && !paused; + const ImVec4 status_color = live ? color_rgb(38, 135, 67) : (paused ? color_rgb(168, 119, 34) : color_rgb(155, 63, 63)); + ImGui::TextColored(status_color, "%s %s", live ? "●" : "○", stream.source_label.c_str()); + ImGui::TextDisabled("%s%s", stream_source_kind_label(stream.source_kind), paused ? " paused" : ""); + const double span = session->route_data.has_time_range ? (session->route_data.x_max - session->route_data.x_min) : 0.0; + const float fill = stream.buffer_seconds <= 0.0 + ? 0.0f + : std::clamp(static_cast(span / stream.buffer_seconds), 0.0f, 1.0f); + ImGui::ProgressBar(fill, ImVec2(-FLT_MIN, 0.0f), nullptr); + ImGui::TextDisabled("%.0fs buffer | %zu series", session->stream_buffer_seconds, session->route_data.series.size()); + const char *button_label = paused ? "Resume" : "Pause"; + if (ImGui::Button(button_label, ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { + if (paused) { + start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, true); + } else { + stop_stream_session(session, state); + state->status_text = "Paused stream " + stream_source_target_label(session->stream_source); + } + } + } else if (session->route_name.empty()) { + ImGui::TextDisabled("No route loaded"); + } + if (!session->route_data.car_fingerprint.empty()) { + ImGui::TextWrapped("Car: %s", session->route_data.car_fingerprint.c_str()); + } + const std::vector dbc_names = available_dbc_names(); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##dbc_combo", dbc_combo_label(*session).c_str())) { + const bool auto_selected = session->dbc_override.empty(); + if (ImGui::Selectable("Auto", auto_selected)) { + apply_dbc_override_change(session, state, {}); + } + if (auto_selected) { + ImGui::SetItemDefaultFocus(); + } + ImGui::Separator(); + for (const std::string &dbc_name : dbc_names) { + const bool selected = session->dbc_override == dbc_name; + if (ImGui::Selectable(dbc_name.c_str(), selected) && !selected) { + apply_dbc_override_change(session, state, dbc_name); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + ImGui::SeparatorText("Layout"); + ImGui::SetNextItemWidth(-FLT_MIN); + const std::string layout_combo_label = [&] { + const std::string base = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); + return state->layout_dirty ? base + " *" : base; + }(); + if (ImGui::BeginCombo("##layout_combo", layout_combo_label.c_str())) { + if (ImGui::Selectable("New Layout")) { + start_new_layout(session, state); + } + ImGui::Separator(); + const std::vector layouts = available_layout_names(); + const std::string current_layout = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); + for (const std::string &layout_name : layouts) { + const bool selected = layout_name == current_layout; + if (ImGui::Selectable(layout_name.c_str(), selected) && !selected) { + reload_layout(session, state, layout_name); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + const float layout_button_gap = ImGui::GetStyle().ItemSpacing.x; + const float layout_row_width = std::max(1.0f, ImGui::GetContentRegionAvail().x); + const float layout_button_width = std::max(1.0f, (layout_row_width - 2.0f * layout_button_gap) / 3.0f); + if (ImGui::Button("New", ImVec2(layout_button_width, 0.0f))) { + start_new_layout(session, state); + } + ImGui::SameLine(0.0f, layout_button_gap); + if (ImGui::Button("Save", ImVec2(layout_button_width, 0.0f))) { + state->request_save_layout = true; + } + ImGui::SameLine(0.0f, layout_button_gap); + ImGui::BeginDisabled(!state->layout_dirty); + if (ImGui::Button("Reset", ImVec2(layout_button_width, 0.0f))) { + state->request_reset_layout = true; + } + ImGui::EndDisabled(); + ImGui::Spacing(); + + ImGui::SeparatorText("Data Sources"); + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_with_hint_string("##browser_filter", "Search...", &state->browser_filter); + const float footer_height = ImGui::GetFrameHeightWithSpacing() + + ImGui::GetTextLineHeightWithSpacing() + + 16.0f + + (show_load_progress ? (ImGui::GetFrameHeightWithSpacing() + 12.0f) : 0.0f); + const float browser_height = std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 2.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 3.0f)); + if (ImGui::BeginChild("##timeseries_browser", ImVec2(0.0f, browser_height), true)) { + const std::string filter = lowercase_copy(state->browser_filter); + std::vector visible_paths; + for (const BrowserNode &node : session->browser_nodes) { + collect_visible_leaf_paths(node, filter, &visible_paths); + } + for (const SpecialItemSpec &spec : kSpecialItemSpecs) { + draw_browser_special_item(spec.id, spec.label); + } + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + ImGui::Separator(); + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + for (const BrowserNode &node : session->browser_nodes) { + draw_browser_node(session, node, state, filter, visible_paths); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(2); + + ImGui::SeparatorText("Custom Series"); + if (ImGui::Button("Create...", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { + open_custom_series_editor(state, state->selected_browser_path); + } + if (show_load_progress) { + const float total = static_cast(std::max(1, load.total_segments)); + const bool finalizing = load.active + && load.total_segments > 0 + && load.segments_downloaded >= load.total_segments + && load.segments_parsed >= load.total_segments; + const float progress = load.total_segments == 0 + ? 0.0f + : (finalizing + ? 0.99f + : std::clamp(static_cast(load.segments_downloaded + load.segments_parsed) / (2.0f * total), 0.0f, 0.99f)); + ImGui::Dummy(ImVec2(0.0f, 8.0f)); + ImGui::ProgressBar(progress, ImVec2(-FLT_MIN, 0.0f), finalizing ? "Finalizing..." : nullptr); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); +} + +std::string app_curve_display_name(const Curve &curve) { + if (!curve.label.empty()) return curve.label; + if (!curve.name.empty()) return curve.name; + return "curve"; +} + +Curve make_curve_for_path(const Pane &pane, const std::string &path) { + Curve curve; + curve.name = path; + curve.label = path; + curve.color = app_next_curve_color(pane); + return curve; +} + +bool add_curve_to_pane(WorkspaceTab *tab, int pane_index, Curve curve) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + if (pane.kind != PaneKind::Plot) { + pane.kind = PaneKind::Plot; + if (is_default_special_title(pane.title)) { + pane.title = UNTITLED_PANE_TITLE; + } + } + for (Curve &existing : pane.curves) { + const bool same_named_curve = !curve.name.empty() && existing.name == curve.name; + const bool same_unnamed_curve = curve.name.empty() && existing.name.empty() && existing.label == curve.label; + if (same_named_curve || same_unnamed_curve) { + existing.visible = true; + return false; + } + } + pane.curves.push_back(std::move(curve)); + return true; +} + +bool add_path_curve_to_pane(AppSession *session, UiState *state, int pane_index, const std::string &path) { + if (app_find_route_series(*session, path) == nullptr) { + state->status_text = "Path not found in route"; + return false; + } + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return false; + } + const SketchLayout before_layout = session->layout; + const bool inserted = add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path)); + bool autosave_ok = true; + if (inserted) { + state->undo.push(before_layout); + autosave_ok = mark_layout_dirty(session, state); + } + if (autosave_ok) { + state->status_text = inserted ? "Added " + path : "Curve already present"; + } + return true; +} + +int add_path_curves_to_pane(AppSession *session, UiState *state, int pane_index, const std::vector &paths) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return 0; + } + + int inserted_count = 0; + int duplicate_count = 0; + const SketchLayout before_layout = session->layout; + for (const std::string &path : paths) { + if (app_find_route_series(*session, path) == nullptr) continue; + if (add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path))) { + ++inserted_count; + } else { + ++duplicate_count; + } + } + + if (inserted_count > 0) { + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = inserted_count == 1 + ? "Added " + paths.front() + : "Added " + std::to_string(inserted_count) + " curves"; + } + return inserted_count; + } + + if (duplicate_count > 0) { + state->status_text = duplicate_count == 1 ? "Curve already present" : "Curves already present"; + } else { + state->status_text = "No matching series found"; + } + return 0; +} + +bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path) { + const TabUiState *tab_state = app_active_tab_state(state); + if (tab_state == nullptr) { + state->status_text = "No active pane"; + return false; + } + return add_path_curve_to_pane(session, state, tab_state->active_pane_index, path); +} + +bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pane_index, std::string_view item_id) { + if (tab == nullptr || tab_state == nullptr) return false; + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) return false; + const SpecialItemSpec *spec = special_item_spec(item_id); + if (spec == nullptr) return false; + Pane &pane = tab->panes[static_cast(pane_index)]; + if (!((pane.kind == PaneKind::Plot && pane.curves.empty()) || pane_kind_is_special(pane.kind))) { + return false; + } + if (pane.kind == spec->kind && (spec->kind != PaneKind::Camera || pane.camera_view == spec->camera_view)) { + tab_state->active_pane_index = pane_index; + return false; + } + const PaneKind previous_kind = pane.kind; + pane.kind = spec->kind; + pane.camera_view = spec->camera_view; + if (spec->kind == PaneKind::Map) { + if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { + pane.title = spec->label; + } + } else { + pane.title = spec->label; + resize_tab_pane_state(tab_state, tab->panes.size()); + tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } + tab_state->active_pane_index = pane_index; + return true; +} + +bool split_pane(WorkspaceTab *tab, int pane_index, PaneDropZone zone, std::optional curve = std::nullopt) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (zone == PaneDropZone::Center) return false; + + const int new_pane_index = static_cast(tab->panes.size()); + Pane new_pane = make_empty_pane(); + if (curve.has_value()) { + new_pane.curves.push_back(*curve); + } + tab->panes.push_back(std::move(new_pane)); + + const bool vertical = zone == PaneDropZone::Top || zone == PaneDropZone::Bottom; + const bool new_before = zone == PaneDropZone::Left || zone == PaneDropZone::Top; + return split_pane_node(&tab->root, pane_index, + vertical ? SplitOrientation::Vertical : SplitOrientation::Horizontal, + new_before, new_pane_index); +} + +bool close_pane(WorkspaceTab *tab, int pane_index) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (tab->panes.size() <= 1) { + tab->panes[static_cast(pane_index)] = make_empty_pane(); + return true; + } + if (remove_pane_node(&tab->root, pane_index)) return false; + tab->panes.erase(tab->panes.begin() + static_cast(pane_index)); + decrement_pane_indices(&tab->root, pane_index); + normalize_split_node(&tab->root); + return true; +} + +void clear_pane(WorkspaceTab *tab, int pane_index) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + pane.curves.clear(); + pane.title = UNTITLED_PANE_TITLE; +} + +void create_runtime_tab(SketchLayout *layout, UiState *state) { + const std::string tab_name = next_tab_name(*layout, "tab1"); + layout->tabs.push_back(make_empty_tab(tab_name)); + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0, .runtime_id = state->next_tab_runtime_id++}); + request_tab_selection(state, static_cast(layout->tabs.size()) - 1); + state->status_text = "Created " + tab_name; +} + +void duplicate_runtime_tab(SketchLayout *layout, UiState *state) { + if (layout->tabs.empty()) { + return; + } + const int source_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + WorkspaceTab copy = layout->tabs[static_cast(source_index)]; + copy.tab_name = next_tab_name(*layout, copy.tab_name + " copy"); + layout->tabs.push_back(std::move(copy)); + const int active_pane_index = source_index < static_cast(state->tabs.size()) ? state->tabs[static_cast(source_index)].active_pane_index : 0; + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = active_pane_index, .runtime_id = state->next_tab_runtime_id++}); + request_tab_selection(state, static_cast(layout->tabs.size()) - 1); + state->status_text = "Duplicated tab"; +} + +void close_runtime_tab(SketchLayout *layout, UiState *state) { + if (layout->tabs.empty()) { + return; + } + const int tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + if (layout->tabs.size() == 1) { + layout->tabs[0] = make_empty_tab(layout->tabs[0].tab_name.empty() ? "tab1" : layout->tabs[0].tab_name); + if (state->tabs.empty()) { + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0}); + } else { + state->tabs.resize(1); + state->tabs[0] = TabUiState{ + .dock_needs_build = true, + .active_pane_index = 0, + .runtime_id = state->tabs[0].runtime_id == 0 ? state->next_tab_runtime_id++ : state->tabs[0].runtime_id, + }; + } + state->active_tab_index = 0; + state->requested_tab_index = 0; + layout->current_tab_index = 0; + cancel_rename_tab(state); + state->status_text = "Closed tab"; + return; + } + layout->tabs.erase(layout->tabs.begin() + static_cast(tab_index)); + if (tab_index < static_cast(state->tabs.size())) { + state->tabs.erase(state->tabs.begin() + static_cast(tab_index)); + } + if (state->active_tab_index >= static_cast(layout->tabs.size())) { + state->active_tab_index = static_cast(layout->tabs.size()) - 1; + } + sync_ui_state(state, *layout); + state->requested_tab_index = state->active_tab_index; + state->status_text = "Closed tab"; +} + +void rename_runtime_tab(SketchLayout *layout, UiState *state) { + if (state->rename_tab_index < 0 || state->rename_tab_index >= static_cast(layout->tabs.size())) { + return; + } + layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer; + state->status_text = "Renamed tab"; + layout->current_tab_index = state->rename_tab_index; + cancel_rename_tab(state); +} + +void draw_inline_tab_editor(AppSession *session, UiState *state, const ImRect &tab_rect) { + const int rename_tab_index = state->rename_tab_index; + if (rename_tab_index < 0 || rename_tab_index >= static_cast(session->layout.tabs.size())) { + return; + } + + const float width = std::max(48.0f, tab_rect.Max.x - tab_rect.Min.x - 10.0f); + const ImVec2 pos = ImVec2(tab_rect.Min.x + 5.0f, tab_rect.Min.y + 2.0f); + ImGui::SetCursorScreenPos(pos); + ImGui::PushItemWidth(width); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, 2.0f)); + if (state->focus_rename_tab_input) { + ImGui::SetKeyboardFocusHere(); + state->focus_rename_tab_input = false; + } + const bool submitted = input_text_string("##rename_tab_inline", + &state->rename_tab_buffer, + ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue); + const bool active = ImGui::IsItemActive(); + const bool escape = active && ImGui::IsKeyPressed(ImGuiKey_Escape); + const bool deactivated = ImGui::IsItemDeactivated(); + ImGui::PopStyleVar(); + ImGui::PopItemWidth(); + + if (escape) { + cancel_rename_tab(state); + } else if (submitted || deactivated) { + const SketchLayout before_layout = session->layout; + rename_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + } +} + + +std::optional draw_pane_drop_target(int tab_index, int pane_index, const Pane &target_pane) { + if (ImGui::GetDragDropPayload() == nullptr) return std::nullopt; + + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); + ImRect content_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)); + content_rect.Expand(ImVec2(-6.0f, -6.0f)); + if (content_rect.GetWidth() < 60.0f || content_rect.GetHeight() < 60.0f) { + return std::nullopt; + } + + const float edge_w = std::min(90.0f, content_rect.GetWidth() * 0.24f); + const float edge_h = std::min(72.0f, content_rect.GetHeight() * 0.24f); + struct ZoneRect { + PaneDropZone zone; + ImRect rect; + }; + const std::array zones = {{ + {PaneDropZone::Left, ImRect(content_rect.Min, ImVec2(content_rect.Min.x + edge_w, content_rect.Max.y))}, + {PaneDropZone::Right, ImRect(ImVec2(content_rect.Max.x - edge_w, content_rect.Min.y), content_rect.Max)}, + {PaneDropZone::Top, ImRect(content_rect.Min, ImVec2(content_rect.Max.x, content_rect.Min.y + edge_h))}, + {PaneDropZone::Bottom, ImRect(ImVec2(content_rect.Min.x, content_rect.Max.y - edge_h), content_rect.Max)}, + {PaneDropZone::Center, ImRect(ImVec2(content_rect.Min.x + edge_w, content_rect.Min.y + edge_h), + ImVec2(content_rect.Max.x - edge_w, content_rect.Max.y - edge_h))}, + }}; + + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + for (const ZoneRect &zone : zones) { + if (zone.rect.GetWidth() <= 0.0f || zone.rect.GetHeight() <= 0.0f) { + continue; + } + + ImGui::PushID(static_cast(zone.zone) * 1000 + pane_index + tab_index * 100); + ImGui::SetCursorScreenPos(zone.rect.Min); + ImGui::InvisibleButton("##drop_zone", zone.rect.GetSize()); + if (ImGui::BeginDragDropTarget()) { + auto try_accept = [&](const char *type) -> const ImGuiPayload * { + const ImGuiPayload *p = ImGui::AcceptDragDropPayload(type, ImGuiDragDropFlags_AcceptBeforeDelivery); + if (p && p->Preview) { + draw_list->AddRectFilled(zone.rect.Min, zone.rect.Max, IM_COL32(70, 130, 220, 55)); + draw_list->AddRect(zone.rect.Min, zone.rect.Max, IM_COL32(45, 95, 175, 220), 0.0f, 0, 2.0f); + } + return p; + }; + auto deliver = [&](PaneDropAction action) -> std::optional { + action.zone = zone.zone; + action.target_pane_index = pane_index; + ImGui::EndDragDropTarget(); + ImGui::PopID(); + return action; + }; + if (const ImGuiPayload *p = try_accept("JOTP_BROWSER_PATHS"); p && p->Delivery) { + if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { + PaneDropAction action; + action.from_browser = true; + action.browser_paths = decode_browser_drag_payload(static_cast(p->Data)); + return deliver(std::move(action)); + } + } + if (zone.zone != PaneDropZone::Center || (target_pane.kind == PaneKind::Plot && target_pane.curves.empty()) || pane_kind_is_special(target_pane.kind)) { + if (const ImGuiPayload *p = try_accept("JOTP_SPECIAL_ITEM"); p && p->Delivery) { + PaneDropAction action; + action.special_item_id = static_cast(p->Data); + return deliver(std::move(action)); + } + } + if (const ImGuiPayload *p = try_accept("JOTP_PANE_CURVE"); p && p->Delivery) { + if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { + PaneDropAction action; + action.curve_ref = *static_cast(p->Data); + return deliver(std::move(action)); + } + } + ImGui::EndDragDropTarget(); + } + ImGui::PopID(); + } + return std::nullopt; +} + +bool commit_tab_layout_change(AppSession *session, + UiState *state, + WorkspaceTab *tab, + TabUiState *tab_state, + const SketchLayout &before_layout, + std::string_view status_text, + bool dock_changed) { + if (dock_changed) { + mark_tab_dock_dirty(state, state->active_tab_index); + } + resize_tab_pane_state(tab_state, tab->panes.size()); + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = std::string(status_text); + } + return true; +} + +bool apply_pane_menu_action(AppSession *session, UiState *state, int pane_index, + const PaneMenuAction &action) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) return false; + + const int original_pane_count = static_cast(tab->panes.size()); + const SketchLayout before_layout = session->layout; + bool dock_changed = false; + bool layout_changed = false; + std::string_view success_status = "Workspace updated"; + switch (action.kind) { + case PaneMenuActionKind::OpenAxisLimits: + tab_state->active_pane_index = pane_index; + open_axis_limits_editor(*session, state, pane_index); + state->status_text = "Axis limits editor opened"; + return true; + case PaneMenuActionKind::OpenCustomSeries: + tab_state->active_pane_index = pane_index; + open_custom_series_editor(state, preferred_custom_series_source(tab->panes[static_cast(pane_index)])); + state->status_text = "Custom series editor opened"; + return true; + case PaneMenuActionKind::SplitLeft: + case PaneMenuActionKind::SplitRight: + case PaneMenuActionKind::SplitTop: + case PaneMenuActionKind::SplitBottom: { + constexpr PaneDropZone kZones[] = {PaneDropZone::Left, PaneDropZone::Right, PaneDropZone::Top, PaneDropZone::Bottom}; + const auto zone = kZones[static_cast(action.kind) - static_cast(PaneMenuActionKind::SplitLeft)]; + if (split_pane(tab, pane_index, zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + dock_changed = true; + layout_changed = true; + } + break; + } + case PaneMenuActionKind::ResetView: + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + state->suppress_range_side_effects = true; + clamp_shared_range(state, *session); + persist_shared_range_to_tab(tab, *state); + clear_pane_vertical_limits(&tab->panes[static_cast(pane_index)]); + layout_changed = true; + success_status = "Plot view reset"; + break; + case PaneMenuActionKind::ResetHorizontal: + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + state->suppress_range_side_effects = true; + clamp_shared_range(state, *session); + persist_shared_range_to_tab(tab, *state); + layout_changed = true; + success_status = "Horizontal zoom reset"; + break; + case PaneMenuActionKind::ResetVertical: + clear_pane_vertical_limits(&tab->panes[static_cast(pane_index)]); + layout_changed = true; + success_status = "Vertical zoom reset"; + break; + case PaneMenuActionKind::Clear: + clear_pane(tab, pane_index); + tab_state->active_pane_index = pane_index; + layout_changed = true; + break; + case PaneMenuActionKind::Close: + if (close_pane(tab, pane_index)) { + tab_state->active_pane_index = std::clamp(pane_index, 0, static_cast(tab->panes.size()) - 1); + layout_changed = true; + dock_changed = static_cast(tab->panes.size()) != original_pane_count; + } + break; + case PaneMenuActionKind::None: + return false; + } + + if (!layout_changed) { + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, success_status, dock_changed); +} + +bool apply_pane_drop_action(AppSession *session, UiState *state, const PaneDropAction &action) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) return false; + + if (!action.special_item_id.empty()) { + const SpecialItemSpec *spec = special_item_spec(action.special_item_id); + if (spec == nullptr) { + return false; + } + if (action.zone == PaneDropZone::Center) { + if (action.target_pane_index < 0 || action.target_pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (!((tab->panes[static_cast(action.target_pane_index)].kind == PaneKind::Plot + && tab->panes[static_cast(action.target_pane_index)].curves.empty()) + || pane_kind_is_special(tab->panes[static_cast(action.target_pane_index)].kind))) { + state->status_text = std::string(special_item_label(action.special_item_id)) + " can only replace another special pane or use an empty pane"; + return false; + } + const SketchLayout before_layout = session->layout; + const bool changed = apply_special_item_to_pane(tab, tab_state, action.target_pane_index, spec->id); + if (!changed) { + state->status_text = std::string(special_item_label(action.special_item_id)) + " already shown in pane"; + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + std::string(special_item_label(action.special_item_id)) + " added to pane", + false); + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + const bool changed = apply_special_item_to_pane(tab, tab_state, tab_state->active_pane_index, spec->id); + if (!changed) { + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + "Split pane and added " + std::string(special_item_label(action.special_item_id)), + true); + } + return false; + } + + if (action.from_browser) { + if (action.browser_paths.empty()) return false; + if (action.zone == PaneDropZone::Center) { + const int inserted_count = add_path_curves_to_pane(session, state, action.target_pane_index, action.browser_paths); + if (inserted_count > 0) { + tab_state->active_pane_index = action.target_pane_index; + } + return inserted_count > 0; + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + int inserted_count = 0; + for (const std::string &path : action.browser_paths) { + if (app_find_route_series(*session, path) == nullptr) continue; + if (add_curve_to_pane(tab, tab_state->active_pane_index, + make_curve_for_path(tab->panes[static_cast(tab_state->active_pane_index)], path))) { + ++inserted_count; + } + } + if (inserted_count > 0) { + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + inserted_count == 1 + ? "Split pane and added " + action.browser_paths.front() + : "Split pane and added " + std::to_string(inserted_count) + " curves", + true); + } + return false; + } + return false; + } + + if (action.curve_ref.tab_index < 0 + || action.curve_ref.tab_index >= static_cast(session->layout.tabs.size())) { + return false; + } + WorkspaceTab &source_tab = session->layout.tabs[static_cast(action.curve_ref.tab_index)]; + if (action.curve_ref.pane_index < 0 + || action.curve_ref.pane_index >= static_cast(source_tab.panes.size())) { + return false; + } + const Pane &source_pane = source_tab.panes[static_cast(action.curve_ref.pane_index)]; + if (action.curve_ref.curve_index < 0 + || action.curve_ref.curve_index >= static_cast(source_pane.curves.size())) { + return false; + } + const Curve curve = source_pane.curves[static_cast(action.curve_ref.curve_index)]; + + if (action.zone == PaneDropZone::Center) { + const SketchLayout before_layout = session->layout; + const bool inserted = add_curve_to_pane(tab, action.target_pane_index, curve); + tab_state->active_pane_index = action.target_pane_index; + if (inserted) { + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = "Added " + app_curve_display_name(curve); + } + } else { + state->status_text = "Curve already present"; + } + return true; + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone, curve)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + "Split pane and added " + app_curve_display_name(curve), + true); + } + return false; +} + +ImGuiDir dock_direction(SplitOrientation orientation) { + return orientation == SplitOrientation::Horizontal ? ImGuiDir_Left : ImGuiDir_Up; +} + +void build_dock_tree(const WorkspaceNode &node, const WorkspaceTab &tab, int tab_runtime_id, ImGuiID dock_id) { + if (node.is_pane) { + if (node.pane_index >= 0 && node.pane_index < static_cast(tab.panes.size())) { + ImGui::DockBuilderDockWindow( + pane_window_name(tab_runtime_id, node.pane_index, tab.panes[static_cast(node.pane_index)]).c_str(), + dock_id); + if (ImGuiDockNode *dock_node = ImGui::DockBuilderGetNode(dock_id); dock_node != nullptr) { + dock_node->LocalFlags |= ImGuiDockNodeFlags_AutoHideTabBar | + ImGuiDockNodeFlags_NoWindowMenuButton | + ImGuiDockNodeFlags_NoCloseButton; + } + } + return; + } + if (node.children.empty()) { + return; + } + if (node.children.size() == 1) { + build_dock_tree(node.children.front(), tab, tab_runtime_id, dock_id); + return; + } + + float remaining = 1.0f; + ImGuiID current = dock_id; + for (size_t i = 0; i + 1 < node.children.size(); ++i) { + const float child_size = i < node.sizes.size() ? node.sizes[i] : 0.0f; + const float ratio = remaining <= 0.0f ? 0.5f : std::clamp(child_size / remaining, 0.05f, 0.95f); + ImGuiID child_id = 0; + ImGuiID remainder_id = 0; + ImGui::DockBuilderSplitNode(current, dock_direction(node.orientation), ratio, &child_id, &remainder_id); + build_dock_tree(node.children[i], tab, tab_runtime_id, child_id); + current = remainder_id; + remaining = std::max(0.0f, remaining - child_size); + } + build_dock_tree(node.children.back(), tab, tab_runtime_id, current); +} + +void ensure_dockspace(const WorkspaceTab &tab, TabUiState *tab_state, ImVec2 dockspace_size) { + if (dockspace_size.x <= 0.0f || dockspace_size.y <= 0.0f || tab_state == nullptr) { + return; + } + const bool size_changed = std::abs(tab_state->last_dockspace_size.x - dockspace_size.x) > 1.0f + || std::abs(tab_state->last_dockspace_size.y - dockspace_size.y) > 1.0f; + if (!tab_state->dock_needs_build && !size_changed) { + return; + } + + const ImGuiID dockspace_id = dockspace_id_for_tab(tab_state->runtime_id); + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace | ImGuiDockNodeFlags_AutoHideTabBar); + ImGui::DockBuilderSetNodeSize(dockspace_id, dockspace_size); + build_dock_tree(tab.root, tab, tab_state->runtime_id, dockspace_id); + ImGui::DockBuilderFinish(dockspace_id); + tab_state->dock_needs_build = false; + tab_state->last_dockspace_size = dockspace_size; +} + +void draw_pane_windows(AppSession *session, UiState *state) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) { + return; + } + + std::optional> pending_menu_action; + std::optional pending_close_pane; + std::optional pending_drop_action; + + for (size_t i = 0; i < tab->panes.size(); ++i) { + Pane &pane = tab->panes[i]; + std::optional menu_action; + std::optional drop_action; + bool close_pane_requested = false; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(250, 250, 251)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(194, 198, 204)); + ImGui::PushStyleColor(ImGuiCol_TitleBg, color_rgb(252, 252, 253)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, color_rgb(252, 252, 253)); + ImGui::PushStyleColor(ImGuiCol_TitleBgCollapsed, color_rgb(252, 252, 253)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + const std::string window_name = pane_window_name(tab_state->runtime_id, static_cast(i), pane); + const bool opened = ImGui::Begin(window_name.c_str(), nullptr, flags); + if (opened) { + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) + || (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) && ImGui::IsMouseClicked(0))) { + tab_state->active_pane_index = static_cast(i); + } + if (pane.kind == PaneKind::Map) { + draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Camera) { + draw_camera_pane(session, state, tab_state, static_cast(i), pane); + } else { + draw_plot(*session, &pane, state); + } + draw_pane_frame_overlay(); + close_pane_requested = draw_pane_close_button_overlay(); + menu_action = draw_pane_context_menu(*tab, static_cast(i)); + drop_action = draw_pane_drop_target(state->active_tab_index, static_cast(i), pane); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + if (!pending_menu_action.has_value() && menu_action.has_value()) { + pending_menu_action = std::make_pair(static_cast(i), *menu_action); + } + if (!pending_menu_action.has_value() && !pending_close_pane.has_value() && close_pane_requested) { + pending_close_pane = static_cast(i); + } + if (!pending_menu_action.has_value() && !pending_close_pane.has_value() + && !pending_drop_action.has_value() && drop_action.has_value()) { + pending_drop_action = *drop_action; + } + } + + if (pending_menu_action.has_value()) { + apply_pane_menu_action(session, state, pending_menu_action->first, pending_menu_action->second); + return; + } + if (pending_close_pane.has_value()) { + PaneMenuAction action; + action.kind = PaneMenuActionKind::Close; + action.pane_index = *pending_close_pane; + apply_pane_menu_action(session, state, *pending_close_pane, action); + return; + } + if (pending_drop_action.has_value()) { + apply_pane_drop_action(session, state, *pending_drop_action); + } +} + +void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { + state->custom_series.selected = false; + state->logs.selected = false; + ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); + ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(244, 246, 248)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(186, 191, 198)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse; + if (ImGui::Begin("##workspace_host", nullptr, flags)) { + const int selection_request = state->requested_tab_index; + std::optional rename_tab_rect; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(8.0f, 4.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + if (ImGui::BeginTabBar("##layout_tabs", ImGuiTabBarFlags_FittingPolicyScroll)) { + enum class TabActionKind { + None, + New, + Rename, + Duplicate, + Close, + }; + TabActionKind pending_action = TabActionKind::None; + int pending_tab_index = -1; + bool custom_series_tab_open = state->custom_series.open; + bool suppress_aux_tabs_this_frame = state->request_close_tab && session->layout.tabs.size() == 1; + for (size_t i = 0; i < session->layout.tabs.size(); ++i) { + const WorkspaceTab &tab = session->layout.tabs[i]; + const TabUiState &tab_ui = state->tabs[i]; + ImGuiTabItemFlags tab_flags = ImGuiTabItemFlags_None; + if (static_cast(i) == selection_request) { + tab_flags |= ImGuiTabItemFlags_SetSelected; + } + bool tab_open = true; + const bool opened = ImGui::BeginTabItem(tab_item_label(tab, tab_ui.runtime_id).c_str(), &tab_open, tab_flags); + if (state->rename_tab_index == static_cast(i)) { + rename_tab_rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + pending_action = TabActionKind::Rename; + pending_tab_index = static_cast(i); + } + if (!tab_open) { + pending_action = TabActionKind::Close; + pending_tab_index = static_cast(i); + if (session->layout.tabs.size() == 1) { + suppress_aux_tabs_this_frame = true; + } + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("New Tab")) { + pending_action = TabActionKind::New; + } + if (ImGui::MenuItem("Rename Tab...")) { + pending_action = TabActionKind::Rename; + pending_tab_index = static_cast(i); + } + if (ImGui::MenuItem("Duplicate Tab")) { + pending_action = TabActionKind::Duplicate; + pending_tab_index = static_cast(i); + } + if (ImGui::MenuItem("Close Tab")) { + pending_action = TabActionKind::Close; + pending_tab_index = static_cast(i); + } + ImGui::EndPopup(); + } + if (opened) { + state->active_tab_index = static_cast(i); + session->layout.current_tab_index = state->active_tab_index; + if (i < state->tabs.size()) { + ensure_dockspace(tab, &state->tabs[i], ImGui::GetContentRegionAvail()); + } + ImGui::DockSpace(dockspace_id_for_tab(tab_ui.runtime_id), + ImVec2(0.0f, 0.0f), + ImGuiDockNodeFlags_AutoHideTabBar | + ImGuiDockNodeFlags_NoWindowMenuButton | + ImGuiDockNodeFlags_NoCloseButton); + ImGui::EndTabItem(); + } + } + if (!suppress_aux_tabs_this_frame) { + ImGuiTabItemFlags logs_flags = ImGuiTabItemFlags_None; + if (state->logs.request_select) { + logs_flags |= ImGuiTabItemFlags_SetSelected; + } + if (ImGui::BeginTabItem("Logs##workspace_logs", nullptr, logs_flags)) { + state->logs.request_select = false; + state->logs.selected = true; + draw_logs_tab(session, state); + ImGui::EndTabItem(); + } + if (custom_series_tab_open) { + ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; + if (state->custom_series.request_select) { + custom_flags |= ImGuiTabItemFlags_SetSelected; + } + if (ImGui::BeginTabItem("Custom Series##workspace_custom_series", &custom_series_tab_open, custom_flags)) { + state->custom_series.request_select = false; + state->custom_series.selected = true; + draw_custom_series_editor(session, state); + ImGui::EndTabItem(); + } + } + } + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f, 5.0f)); + ImGui::PushStyleColor(ImGuiCol_Tab, color_rgb(210, 217, 225)); + ImGui::PushStyleColor(ImGuiCol_TabHovered, color_rgb(224, 230, 237)); + ImGui::PushStyleColor(ImGuiCol_TabSelected, color_rgb(242, 245, 248)); + if (ImGui::TabItemButton(" ##new_tab_button", ImGuiTabItemFlags_Trailing)) { + pending_action = TabActionKind::New; + } + { + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(color_rgb(72, 79, 88)); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + constexpr float half_extent = 6.25f; + constexpr float thickness = 2.0f; + draw_list->AddLine(ImVec2(center.x - half_extent, center.y), + ImVec2(center.x + half_extent, center.y), + color, + thickness); + draw_list->AddLine(ImVec2(center.x, center.y - half_extent), + ImVec2(center.x, center.y + half_extent), + color, + thickness); + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); + ImGui::BeginTooltip(); + ImGui::TextUnformatted("New Tab"); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + ImGui::EndTabBar(); + + if (!custom_series_tab_open) { + state->custom_series.open = false; + state->custom_series.request_select = false; + } + + if (rename_tab_rect.has_value()) { + draw_inline_tab_editor(session, state, *rename_tab_rect); + } + + if (state->request_new_tab || pending_action == TabActionKind::New) { + const SketchLayout before_layout = session->layout; + create_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_new_tab = false; + } else if (pending_action == TabActionKind::Rename) { + begin_rename_tab(session->layout, state, pending_tab_index); + } else if (state->request_duplicate_tab || pending_action == TabActionKind::Duplicate) { + if (pending_tab_index >= 0) { + request_tab_selection(state, pending_tab_index); + } + const SketchLayout before_layout = session->layout; + duplicate_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_duplicate_tab = false; + } else if (state->request_close_tab || pending_action == TabActionKind::Close) { + if (pending_tab_index >= 0) { + request_tab_selection(state, pending_tab_index); + } + const SketchLayout before_layout = session->layout; + close_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_close_tab = false; + } + if (state->requested_tab_index == selection_request) { + state->requested_tab_index = -1; + } + } + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); +} + +int run(const Options &options) { + try { + const fs::path layout_path = options.layout.empty() ? fs::path() : resolve_layout_path(options.layout); + AppSession session = { + .layout_path = layout_path, + .autosave_path = layout_path.empty() ? fs::path() : autosave_path_for_layout(layout_path), + .route_name = options.route_name, + .data_dir = options.data_dir, + .dbc_override = {}, + .stream_source = StreamSourceConfig{.kind = is_local_stream_address(options.stream_address) + ? StreamSourceKind::CerealLocal + : StreamSourceKind::CerealRemote, + .address = options.stream_address}, + .stream_buffer_seconds = options.stream_buffer_seconds, + .data_mode = options.stream ? SessionDataMode::Stream : SessionDataMode::Route, + .route_id = options.stream ? RouteIdentifier{} : parse_route_identifier(options.route_name), + .layout = options.layout.empty() ? make_empty_layout() : load_sketch_layout(layout_path), + }; + UiState ui_state; + if (!layout_path.empty() && !session.autosave_path.empty() && fs::exists(session.autosave_path)) { + session.layout = load_sketch_layout(session.autosave_path); + ui_state.layout_dirty = true; + } + ui_state.undo.reset(session.layout); + sync_ui_state(&ui_state, session.layout); + sync_route_buffers(&ui_state, session); + sync_stream_buffers(&ui_state, session); + sync_layout_buffers(&ui_state, session); + + session.async_route_loading = session.data_mode == SessionDataMode::Route + && options.show && options.output_path.empty() && !options.sync_load; + if (session.data_mode == SessionDataMode::Route && !session.async_route_loading) { + TerminalRouteProgress route_progress(::isatty(STDERR_FILENO) != 0); + rebuild_session_route_data(&session, &ui_state, [&](const RouteLoadProgress &update) { + route_progress.update(update); + }); + route_progress.finish(); + } + + GlfwRuntime glfw_runtime(options); + ImGuiRuntime imgui_runtime(glfw_runtime.window()); + configure_style(); + session.map_data = std::make_unique(); + for (std::unique_ptr &feed : session.pane_camera_feeds) { + feed = std::make_unique(); + } + sync_camera_feeds(&session); + + if (session.async_route_loading) { + session.route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); + start_async_route_load(&session, &ui_state); + } else if (session.data_mode == SessionDataMode::Stream) { + session.stream_poller = std::make_unique(); + start_stream_session(&session, &ui_state, session.stream_source, session.stream_buffer_seconds); + } + + const bool should_capture = !options.output_path.empty(); + const fs::path output_path = should_capture ? fs::path(options.output_path) : fs::path(); + const bool capture_has_map = should_capture && active_tab_has_map_pane(session.layout); + if (options.show) { + bool captured = false; + const auto capture_ready_at = std::chrono::steady_clock::now() + (capture_has_map ? std::chrono::milliseconds(1800) + : std::chrono::milliseconds(0)); + while (!glfwWindowShouldClose(glfw_runtime.window())) { + const bool capture_ready = std::chrono::steady_clock::now() >= capture_ready_at; + const fs::path *capture_path = (!captured && should_capture && capture_ready) ? &output_path : nullptr; + render_frame(glfw_runtime.window(), &session, &ui_state, capture_path); + captured = captured || capture_path != nullptr; + } + } else { + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + if (should_capture) { + for (int i = 0; i < 3; ++i) { + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + } + if (capture_has_map) { + for (int i = 0; i < 18; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + } + } + render_frame(glfw_runtime.window(), &session, &ui_state, &output_path); + } + } + if (session.stream_poller) { + session.stream_poller->stop(); + } + session.map_data.reset(); + for (std::unique_ptr &feed : session.pane_camera_feeds) { + feed.reset(); + } + return 0; + } catch (const std::exception &err) { + std::cerr << err.what() << "\n"; + return 1; + } +} diff --git a/tools/jotpluggler/app.h b/tools/jotpluggler/app.h new file mode 100644 index 000000000..71f71f2d9 --- /dev/null +++ b/tools/jotpluggler/app.h @@ -0,0 +1,884 @@ +#pragma once + +#include "cereal/gen/cpp/log.capnp.h" +#include "imgui.h" +#include "tools/jotpluggler/dbc.h" +#include "tools/jotpluggler/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ***** +// app options & entry point +// ***** + +struct Options { + std::string layout; + std::string route_name; + std::string data_dir; + std::string output_path; + std::string stream_address = "127.0.0.1"; + int width = 1600; + int height = 900; + bool show = false; + bool sync_load = false; + bool stream = false; + double stream_buffer_seconds = 30.0; +}; + +int run(const Options &options); + +// ***** +// sketch layout & route data +// ***** + +struct PlotRange { + bool valid = false; + double left = 0.0; + double right = 0.0; + double bottom = 0.0; + double top = 1.0; + bool has_y_limit_min = false; + bool has_y_limit_max = false; + double y_limit_min = 0.0; + double y_limit_max = 1.0; +}; + +struct CustomPythonSeries { + std::string linked_source; + std::vector additional_sources; + std::string globals_code; + std::string function_code; +}; + +struct Curve { + std::string name; + std::string label; + std::array color = {160, 170, 180}; + bool visible = true; + bool derivative = false; + double derivative_dt = 0.0; + double value_scale = 1.0; + double value_offset = 0.0; + bool runtime_only = false; + std::optional custom_python; + std::string runtime_error_message; + std::vector xs; + std::vector ys; +}; + +enum class PaneKind : uint8_t { + Plot, + Map, + Camera, +}; + +enum class CameraViewKind : uint8_t { + Road, + Driver, + WideRoad, + QRoad, +}; + +struct Pane { + PaneKind kind = PaneKind::Plot; + CameraViewKind camera_view = CameraViewKind::Road; + std::string title; + PlotRange range; + std::vector curves; +}; + +enum class SplitOrientation { + Horizontal, + Vertical, +}; + +struct WorkspaceNode { + bool is_pane = false; + int pane_index = -1; + SplitOrientation orientation = SplitOrientation::Horizontal; + std::vector sizes; + std::vector children; +}; + +struct WorkspaceTab { + std::string tab_name; + WorkspaceNode root; + std::vector panes; +}; + +struct RouteSeries { + std::string path; + std::vector times; + std::vector values; +}; + +struct CameraSegmentFile { + int segment = -1; + std::string path; +}; + +struct CameraFrameIndexEntry { + double timestamp = 0.0; + int segment = -1; + int decode_index = -1; + uint32_t frame_id = 0; +}; + +struct CameraFeedIndex { + std::vector segment_files; + std::vector entries; +}; + +enum class LogOrigin : uint8_t { + Log, + Android, + Alert, +}; + +struct LogEntry { + double mono_time = 0.0; + double boot_time = 0.0; + double wall_time = 0.0; + uint8_t level = 20; + std::string source; + std::string func; + std::string message; + std::string context; + LogOrigin origin = LogOrigin::Log; +}; + +struct EnumInfo { + std::vector names; +}; + +struct SeriesFormat { + int decimals = 3; + bool integer_like = false; + bool has_negative = false; + int digits_before = 1; + int total_width = 0; + char fmt[16] = "%7.3f"; +}; + +enum class CanServiceKind : uint8_t { + Can, + Sendcan, +}; + +struct CanMessageId { + CanServiceKind service = CanServiceKind::Can; + uint8_t bus = 0; + uint32_t address = 0; + + bool operator==(const CanMessageId &other) const { + return service == other.service && bus == other.bus && address == other.address; + } +}; + +struct CanMessageIdHash { + size_t operator()(const CanMessageId &id) const { + return (static_cast(id.service) << 40) + ^ (static_cast(id.bus) << 32) + ^ static_cast(id.address); + } +}; + +struct CanFrameSample { + double mono_time = 0.0; + uint16_t bus_time = 0; + std::string data; +}; + +struct LiveCanFrame { + double mono_time = 0.0; + uint8_t bus = 0; + uint32_t address = 0; + uint16_t bus_time = 0; + std::string data; +}; + +struct CanMessageData { + CanMessageId id; + std::vector samples; +}; + +struct TimelineEntry { + enum class Type : uint8_t { + None, + Engaged, + AlertInfo, + AlertWarning, + AlertCritical, + }; + + double start_time = 0.0; + double end_time = 0.0; + Type type = Type::None; +}; + +struct GpsPoint { + double time = 0.0; + double lat = 0.0; + double lon = 0.0; + float bearing = 0.0f; + TimelineEntry::Type type = TimelineEntry::Type::None; +}; + +struct GpsTrace { + std::vector points; + double min_lat = 0.0; + double max_lat = 0.0; + double min_lon = 0.0; + double max_lon = 0.0; +}; + +enum class LogSelector : uint8_t { + Auto, + RLog, + QLog, +}; + +struct RouteIdentifier { + std::string dongle_id; + std::string log_id; + int slice_begin = 0; + int slice_end = -1; + bool slice_explicit = false; + LogSelector selector = LogSelector::Auto; + bool selector_explicit = false; + int available_begin = 0; + int available_end = 0; + + bool empty() const { + return dongle_id.empty() || log_id.empty(); + } + + std::string canonical() const { + return empty() ? std::string() : dongle_id + "/" + log_id; + } + + std::string onebox() const { + return empty() ? std::string() : dongle_id + "|" + log_id; + } + + std::string display_slice() const { + const int begin = slice_explicit ? slice_begin : available_begin; + const int end = slice_explicit ? slice_end : available_end; + if (end < 0 || end == begin) { + return std::to_string(begin); + } + return std::to_string(begin) + ":" + std::to_string(end); + } + + char selector_char() const { + switch (selector) { + case LogSelector::RLog: return 'r'; + case LogSelector::QLog: return 'q'; + case LogSelector::Auto: + default: return 'a'; + } + } + + std::string full_spec() const { + if (empty()) return {}; + std::string spec = dongle_id + "/" + log_id; + if (slice_explicit) { + spec += "/"; + spec += display_slice(); + } + if (selector_explicit) { + spec += "/"; + spec.push_back(selector_char()); + } + return spec; + } +}; + +struct RouteData { + std::vector series; + std::vector paths; + std::vector roots; + std::vector can_messages; + CameraFeedIndex road_camera; + CameraFeedIndex driver_camera; + CameraFeedIndex wide_road_camera; + CameraFeedIndex qroad_camera; + GpsTrace gps_trace; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; + std::unordered_map series_formats; + std::string car_fingerprint; + std::string dbc_name; + RouteIdentifier route_id; + bool has_time_range = false; + double x_min = 0.0; + double x_max = 1.0; +}; + +struct StreamExtractBatch { + std::vector series; + std::vector can_messages; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; + std::string car_fingerprint; + std::string dbc_name; + bool has_time_offset = false; + double time_offset = 0.0; +}; + +struct SketchLayout { + std::vector tabs; + std::vector roots; + int current_tab_index = 0; +}; + +enum class RouteLoadStage { + Resolving, + DownloadingSegment, + ParsingSegment, + Finished, +}; + +struct RouteLoadProgress { + RouteLoadStage stage = RouteLoadStage::Resolving; + size_t segment_index = 0; + size_t segment_count = 0; + uint64_t current = 0; + uint64_t total = 0; + size_t segments_downloaded = 0; + size_t segments_parsed = 0; + size_t total_segments = 0; + uint64_t bytes_downloaded = 0; + int num_workers = 1; + std::string segment_name; +}; + +using RouteLoadProgressCallback = std::function; + +class StreamAccumulator { +public: + explicit StreamAccumulator(const std::string &dbc_name = {}, std::optional time_offset = std::nullopt); + ~StreamAccumulator(); + + StreamAccumulator(const StreamAccumulator &) = delete; + StreamAccumulator &operator=(const StreamAccumulator &) = delete; + + void setDbcName(const std::string &dbc_name); + void appendEvent(cereal::Event::Which which, kj::ArrayPtr data); + void appendCanFrames(CanServiceKind service, const std::vector &frames); + StreamExtractBatch takeBatch(); + const std::string &carFingerprint() const; + const std::string &dbc_name() const; + std::optional timeOffset() const; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +SketchLayout load_sketch_layout(const std::filesystem::path &layout_path); +std::vector available_dbc_names(); +std::vector collect_route_roots_for_paths(const std::vector &paths); +std::optional load_dbc_by_name(const std::string &dbc_name); +std::vector decode_can_messages(const std::vector &can_messages, + const std::string &dbc_name, + std::unordered_map *enum_info = nullptr); +RouteData load_route_data(const std::string &route_name, + const std::string &data_dir = {}, + const std::string &dbc_name = {}, + const RouteLoadProgressCallback &progress = {}); +RouteIdentifier parse_route_identifier(std::string_view route_name); +void rebuild_gps_trace(RouteData *route_data); + +// ***** +// icons +// ***** + +namespace icon { +constexpr const char ARROW_DOWN_UP[] = "\xef\x84\xa7"; +constexpr const char ARROW_LEFT_RIGHT[] = "\xef\x84\xab"; +constexpr const char BAR_CHART[] = "\xef\x85\xbe"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char CLIPBOARD[] = "\xef\x8a\x90"; +constexpr const char CLIPBOARD2[] = "\xef\x9c\xb3"; +constexpr const char DISTRIBUTE_HORIZONTAL[] = "\xef\x8c\x83"; +constexpr const char DISTRIBUTE_VERTICAL[] = "\xef\x8c\x84"; +constexpr const char FILE_EARMARK_IMAGE[] = "\xef\x8d\xad"; +constexpr const char FILES[] = "\xef\x8f\x82"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char PALETTE[] = "\xef\x92\xb1"; +constexpr const char PLUS_SLASH_MINUS[] = "\xef\x9a\xaa"; +constexpr const char SAVE[] = "\xef\x94\xa5"; +constexpr const char SLIDERS[] = "\xef\x95\xab"; +constexpr const char TRASH[] = "\xef\x97\x9e"; +constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +} // namespace icon + +void icon_add_font(float size, bool merge = false, const ImFont *base_font = nullptr); +bool icon_menu_item(const char *glyph, + const char *label, + const char *shortcut = nullptr, + bool selected = false, + bool enabled = true); + +// ***** +// app session, UI state, & internal API +// ***** + +class AsyncRouteLoader; +class CameraFeedView; +class StreamPoller; +class MapDataManager; + +enum class SessionDataMode : uint8_t { + Route, + Stream, +}; + +enum class StreamSourceKind : uint8_t { + CerealLocal, + CerealRemote, +}; + +struct StreamSourceConfig { + StreamSourceKind kind = StreamSourceKind::CerealLocal; + std::string address = "127.0.0.1"; +}; + +struct BrowserNode { + std::string label; + std::string full_path; + std::vector children; +}; + +struct AppSession { + std::filesystem::path layout_path; + std::filesystem::path autosave_path; + std::string route_name; + std::string data_dir; + std::string dbc_override; + StreamSourceConfig stream_source; + double stream_buffer_seconds = 30.0; + SessionDataMode data_mode = SessionDataMode::Route; + RouteIdentifier route_id; + SketchLayout layout; + RouteData route_data; + std::unordered_map series_by_path; + std::vector browser_nodes; + std::unique_ptr route_loader; + std::unique_ptr stream_poller; + std::array, 4> pane_camera_feeds; + std::unique_ptr map_data; + bool async_route_loading = false; + double next_stream_custom_refresh_time = 0.0; + bool stream_paused = false; + std::optional stream_time_offset; +}; + +struct TabUiState { + struct MapPaneState { + bool initialized = false; + bool follow = false; + float zoom = 1.0f; + double center_lat = 0.0; + double center_lon = 0.0; + }; + + struct CameraPaneState { + bool fit_to_pane = true; + }; + + bool dock_needs_build = true; + int active_pane_index = 0; + int runtime_id = 0; + ImVec2 last_dockspace_size = ImVec2(0.0f, 0.0f); + std::vector map_panes; + std::vector camera_panes; +}; + +struct CustomSeriesEditorState { + bool open = false; + bool open_help = false; + bool request_select = false; + bool selected = false; + bool focus_name = false; + int selected_template = 0; + int selected_additional_source = -1; + std::string name; + std::string linked_source; + std::vector additional_sources; + std::string globals_code; + std::string function_code = "return value"; + std::string preview_label; + std::vector preview_xs; + std::vector preview_ys; + bool preview_is_result = false; +}; + +enum class LogTimeMode : uint8_t { + Route, + Boot, + WallClock, +}; + +struct LogsUiState { + bool selected = false; + bool request_select = false; + bool all_sources = true; + uint32_t enabled_levels_mask = 0b11110; + int expanded_index = -1; + std::string search; + std::vector selected_sources; + double last_auto_scroll_time = -1.0; + LogTimeMode time_mode = LogTimeMode::Route; +}; + +struct AxisLimitsEditorState { + bool open = false; + int pane_index = -1; + double x_min = 0.0; + double x_max = 1.0; + bool y_min_enabled = false; + bool y_max_enabled = false; + double y_min = 0.0; + double y_max = 1.0; +}; + +struct DbcEditorState { + bool open = false; + bool loaded = false; + std::string source_name; + std::filesystem::path source_path; + enum class SourceKind : uint8_t { + None, + Generated, + Opendbc, + }; + SourceKind source_kind = SourceKind::None; + std::string save_name; + std::string text; +}; + +enum class TimelineDragMode : uint8_t { + None, + ScrubCursor, + PanViewport, + ResizeLeft, + ResizeRight, +}; + +struct UndoStack { + static constexpr size_t kMaxHistory = 50; + + std::vector history; + int position = -1; + + void reset(const SketchLayout &layout) { + history.clear(); + history.push_back(layout); + position = 0; + } + + void push(const SketchLayout &layout) { + if (position < 0) { + reset(layout); + return; + } + if (position + 1 < static_cast(history.size())) { + history.resize(static_cast(position + 1)); + } + history.push_back(layout); + if (history.size() > kMaxHistory) { + history.erase(history.begin()); + } + position = static_cast(history.size()) - 1; + } + + bool can_undo() const { + return position > 0; + } + + bool can_redo() const { + return position >= 0 && position + 1 < static_cast(history.size()); + } + + const SketchLayout &undo() { + return history[static_cast(--position)]; + } + + const SketchLayout &redo() { + return history[static_cast(++position)]; + } +}; + +struct UiState { + bool open_open_route = false; + bool open_stream = false; + bool open_load_layout = false; + bool open_save_layout = false; + bool open_preferences = false; + bool open_find_signal = false; + bool request_close = false; + bool request_reset_layout = false; + bool request_save_layout = false; + bool request_new_tab = false; + bool request_duplicate_tab = false; + bool request_close_tab = false; + bool follow_latest = false; + bool has_shared_range = false; + bool has_tracker_time = false; + bool layout_dirty = false; + bool playback_loop = false; + bool playback_playing = false; + bool show_deprecated_fields = false; + bool show_fps_overlay = false; + bool fps_overlay_initialized = false; + bool suppress_range_side_effects = false; + bool browser_nodes_dirty = false; + int active_tab_index = 0; + int next_tab_runtime_id = 1; + int requested_tab_index = -1; + int rename_tab_index = -1; + bool focus_rename_tab_input = false; + std::vector tabs; + std::string route_buffer; + std::string stream_address_buffer; + std::string rename_tab_buffer; + std::string browser_filter; + std::string data_dir_buffer; + std::string load_layout_buffer; + std::string save_layout_buffer; + std::string find_signal_buffer; + std::string selected_browser_path; + std::vector selected_browser_paths; + std::string browser_selection_anchor; + std::string route_slice_buffer; + std::string error_text; + bool open_error_popup = false; + std::string status_text = "Ready"; + std::string route_copy_feedback_text; + double route_copy_feedback_until = 0.0; + bool editing_route_slice = false; + bool focus_route_slice_input = false; + StreamSourceKind stream_source_kind = StreamSourceKind::CerealLocal; + float sidebar_width = 320.0f; + double route_x_min = 0.0; + double route_x_max = 1.0; + double x_view_min = 0.0; + double x_view_max = 1.0; + double tracker_time = 0.0; + double playback_rate = 1.0; + double playback_step = 0.1; + double stream_buffer_seconds = 30.0; + TimelineDragMode timeline_drag_mode = TimelineDragMode::None; + double timeline_drag_anchor_time = 0.0; + double timeline_drag_anchor_x_min = 0.0; + double timeline_drag_anchor_x_max = 0.0; + AxisLimitsEditorState axis_limits; + DbcEditorState dbc_editor; + CustomSeriesEditorState custom_series; + LogsUiState logs; + UndoStack undo; +}; + +// app.cc public API + +const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state); +WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state); +TabUiState *app_active_tab_state(UiState *state); + +void app_push_mono_font(); +void app_pop_mono_font(); +bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path); + +std::string app_curve_display_name(const Curve &curve); +std::array app_next_curve_color(const Pane &pane); +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path); +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); +std::optional app_sample_xy_value_at_time(const std::vector &xs, + const std::vector &ys, + bool stairs, + double tm); +void save_layout_json(const SketchLayout &layout, const std::filesystem::path &path); + +// ***** +// browser +// ***** + +void rebuild_route_index(AppSession *session); +void rebuild_browser_nodes(AppSession *session, UiState *state); +SeriesFormat compute_series_format(const std::vector &values, bool enum_like = false); +std::string format_display_value(double display_value, + const SeriesFormat &format, + const EnumInfo *enum_info); +std::vector decode_browser_drag_payload(std::string_view payload); +void collect_visible_leaf_paths(const BrowserNode &node, + const std::string &filter, + std::vector *out); +void draw_browser_node(AppSession *session, + const BrowserNode &node, + UiState *state, + const std::string &filter, + const std::vector &visible_paths); + +// ***** +// custom series +// ***** + +void open_custom_series_editor(UiState *state, const std::string &preferred_source = {}); +std::string preferred_custom_series_source(const Pane &pane); +void refresh_all_custom_curves(AppSession *session, UiState *state); +void draw_custom_series_editor(AppSession *session, UiState *state); + +// ***** +// logs +// ***** + +void draw_logs_tab(AppSession *session, UiState *state); + +// ***** +// map +// ***** + +void draw_map_pane(AppSession *session, UiState *state, Pane *pane, int pane_index); + +// ***** +// runtime (GLFW, async loaders, streaming, camera) +// ***** + +struct GLFWwindow; + +struct RouteLoadSnapshot { + bool active = false; + size_t total_segments = 0; + size_t segments_downloaded = 0; + size_t segments_parsed = 0; +}; + +struct StreamPollSnapshot { + bool active = false; + bool connected = false; + bool paused = false; + StreamSourceKind source_kind = StreamSourceKind::CerealLocal; + std::string source_label; + std::string dbc_name; + std::string car_fingerprint; + double buffer_seconds = 30.0; + uint64_t received_messages = 0; +}; + +class GlfwRuntime { +public: + explicit GlfwRuntime(const Options &options); + ~GlfwRuntime(); + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + + GLFWwindow *window() const; + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window); + ~ImGuiRuntime(); + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +class TerminalRouteProgress { +public: + explicit TerminalRouteProgress(bool enabled); + ~TerminalRouteProgress(); + + TerminalRouteProgress(const TerminalRouteProgress &) = delete; + TerminalRouteProgress &operator=(const TerminalRouteProgress &) = delete; + + void update(const RouteLoadProgress &progress); + void finish(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class AsyncRouteLoader { +public: + explicit AsyncRouteLoader(bool enable_terminal_progress); + ~AsyncRouteLoader(); + + AsyncRouteLoader(const AsyncRouteLoader &) = delete; + AsyncRouteLoader &operator=(const AsyncRouteLoader &) = delete; + + void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name); + RouteLoadSnapshot snapshot() const; + bool consume(RouteData *route_data, std::string *error_text); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class StreamPoller { +public: + StreamPoller(); + ~StreamPoller(); + + StreamPoller(const StreamPoller &) = delete; + StreamPoller &operator=(const StreamPoller &) = delete; + + void start(const StreamSourceConfig &source, + double buffer_seconds, + const std::string &dbc_name, + std::optional time_offset = std::nullopt); + void setPaused(bool paused); + void stop(); + StreamPollSnapshot snapshot() const; + bool consume(StreamExtractBatch *batch, std::string *error_text); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class CameraFeedView { +public: + CameraFeedView(); + ~CameraFeedView(); + + CameraFeedView(const CameraFeedView &) = delete; + CameraFeedView &operator=(const CameraFeedView &) = delete; + + void setRouteData(const RouteData &route_data); + void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view); + void update(double tracker_time); + void draw(float width, bool loading); + void drawSized(ImVec2 size, bool loading, bool fit_to_pane = false); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/tools/jotpluggler/browser.cc b/tools/jotpluggler/browser.cc new file mode 100644 index 000000000..0d1b5a2c1 --- /dev/null +++ b/tools/jotpluggler/browser.cc @@ -0,0 +1,465 @@ +#include "tools/jotpluggler/app.h" + +#include "imgui_internal.h" + +#include +#include +#include + +namespace { + +constexpr float BROWSER_VALUE_WIDTH = 88.0f; + +bool path_matches_filter(const std::string &path, const std::string &lower_filter) { + if (lower_filter.empty()) return true; + return lowercase_copy(path).find(lower_filter) != std::string::npos; +} + +void insert_browser_path(std::vector *nodes, const std::string &path) { + size_t start = 0; + while (start < path.size() && path[start] == '/') { + ++start; + } + std::vector parts; + while (start < path.size()) { + const size_t end = path.find('/', start); + parts.push_back(path.substr(start, end == std::string::npos ? std::string::npos : end - start)); + if (end == std::string::npos) break; + start = end + 1; + } + if (parts.empty()) { + return; + } + + std::vector *current_nodes = nodes; + std::string current_path; + for (size_t i = 0; i < parts.size(); ++i) { + if (!current_path.empty()) { + current_path += "/"; + } + current_path += parts[i]; + auto it = std::find_if(current_nodes->begin(), current_nodes->end(), + [&](const BrowserNode &node) { return node.label == parts[i]; }); + if (it == current_nodes->end()) { + current_nodes->push_back(BrowserNode{.label = parts[i]}); + it = std::prev(current_nodes->end()); + } + if (i + 1 == parts.size()) { + it->full_path = "/" + current_path; + } + current_nodes = &it->children; + } +} + +void sort_browser_nodes(std::vector *nodes) { + std::sort(nodes->begin(), nodes->end(), [](const BrowserNode &a, const BrowserNode &b) { + if (a.children.empty() != b.children.empty()) { + return !a.children.empty(); + } + return a.label < b.label; + }); + for (BrowserNode &node : *nodes) { + sort_browser_nodes(&node.children); + } +} + +std::vector build_browser_tree(const std::vector &paths) { + std::vector nodes; + for (const std::string &path : paths) { + insert_browser_path(&nodes, path); + } + sort_browser_nodes(&nodes); + return nodes; +} + +bool is_deprecated_browser_path(const std::string &path) { + return path.find("DEPRECATED") != std::string::npos; +} + +std::vector visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) { + if (show_deprecated_fields) return route_data.paths; + std::vector filtered; + filtered.reserve(route_data.paths.size()); + for (const std::string &path : route_data.paths) { + if (!is_deprecated_browser_path(path)) { + filtered.push_back(path); + } + } + return filtered; +} + +bool browser_selection_contains(const UiState &state, std::string_view path) { + return std::find(state.selected_browser_paths.begin(), state.selected_browser_paths.end(), path) + != state.selected_browser_paths.end(); +} + +std::vector browser_drag_paths(const UiState &state, const std::string &dragged_path) { + if (browser_selection_contains(state, dragged_path) && !state.selected_browser_paths.empty()) { + return state.selected_browser_paths; + } + return {dragged_path}; +} + +std::string encode_browser_drag_payload(const std::vector &paths) { + std::string payload; + for (size_t i = 0; i < paths.size(); ++i) { + if (i != 0) { + payload.push_back('\n'); + } + payload += paths[i]; + } + return payload; +} + +void set_browser_selection_single(UiState *state, const std::string &path) { + state->selected_browser_paths = {path}; + state->selected_browser_path = path; + state->browser_selection_anchor = path; +} + +void toggle_browser_selection(UiState *state, const std::string &path) { + auto it = std::find(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), path); + if (it == state->selected_browser_paths.end()) { + state->selected_browser_paths.push_back(path); + } else { + state->selected_browser_paths.erase(it); + } + state->selected_browser_path = path; + state->browser_selection_anchor = path; + if (state->selected_browser_paths.empty()) { + state->selected_browser_path.clear(); + } +} + +void select_browser_range(UiState *state, const std::vector &visible_paths, const std::string &clicked_path) { + if (visible_paths.empty()) { + set_browser_selection_single(state, clicked_path); + return; + } + + const std::string anchor = state->browser_selection_anchor.empty() ? clicked_path : state->browser_selection_anchor; + const auto anchor_it = std::find(visible_paths.begin(), visible_paths.end(), anchor); + const auto clicked_it = std::find(visible_paths.begin(), visible_paths.end(), clicked_path); + if (clicked_it == visible_paths.end()) { + return; + } + if (anchor_it == visible_paths.end()) { + set_browser_selection_single(state, clicked_path); + return; + } + + const auto [begin_it, end_it] = std::minmax(anchor_it, clicked_it); + std::vector selected; + selected.reserve(static_cast(std::distance(begin_it, end_it)) + 1); + for (auto it = begin_it; it != end_it + 1; ++it) { + selected.push_back(*it); + } + state->selected_browser_paths = std::move(selected); + state->selected_browser_path = clicked_path; +} + +void prune_browser_selection(UiState *state, const std::vector &visible_paths) { + const std::unordered_set visible_set(visible_paths.begin(), visible_paths.end()); + auto is_visible = [&](const std::string &path) { + return visible_set.count(path) > 0; + }; + + state->selected_browser_paths.erase( + std::remove_if(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), + [&](const std::string &path) { return !is_visible(path); }), + state->selected_browser_paths.end()); + + if (!state->selected_browser_path.empty() && !is_visible(state->selected_browser_path)) { + state->selected_browser_path.clear(); + } + if (!state->browser_selection_anchor.empty() && !is_visible(state->browser_selection_anchor)) { + state->browser_selection_anchor.clear(); + } + if (state->selected_browser_paths.empty()) { + state->selected_browser_path.clear(); + } else if (state->selected_browser_path.empty()) { + state->selected_browser_path = state->selected_browser_paths.back(); + } +} + +std::optional sample_route_series_value(const RouteSeries &series, double tm, bool stairs) { + return app_sample_xy_value_at_time(series.times, series.values, stairs, tm); +} + +std::string browser_series_value_text(const AppSession &session, const UiState &state, std::string_view path) { + auto it = session.series_by_path.find(std::string(path)); + if (it == session.series_by_path.end() || it->second == nullptr) return {}; + + const RouteSeries &series = *it->second; + if (series.values.empty()) return {}; + + const auto enum_it = session.route_data.enum_info.find(series.path); + const EnumInfo *enum_info = enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second; + const bool stairs = enum_info != nullptr; + + std::optional value; + if (state.has_tracker_time) { + value = sample_route_series_value(series, state.tracker_time, stairs); + } else { + value = series.values.back(); + } + if (!value.has_value()) return {}; + + const auto display_it = session.route_data.series_formats.find(series.path); + const SeriesFormat display_info = display_it == session.route_data.series_formats.end() + ? compute_series_format(series.values, enum_info != nullptr) + : display_it->second; + + return format_display_value(*value, display_info, enum_info); +} + +bool browser_node_matches(const BrowserNode &node, const std::string &filter) { + if (filter.empty()) return true; + if (!node.full_path.empty() && path_matches_filter(node.full_path, filter)) { + return true; + } + for (const BrowserNode &child : node.children) { + if (browser_node_matches(child, filter)) return true; + } + return false; +} + +} // namespace + +namespace { + +int decimals_needed(double value) { + const double abs_value = std::abs(value); + if (abs_value < 1.0e-12) return 0; + for (int decimals = 0; decimals <= 6; ++decimals) { + const double scale = std::pow(10.0, decimals); + if (std::abs(abs_value * scale - std::round(abs_value * scale)) < 1.0e-6) { + return decimals; + } + } + return 6; +} + +void finalize_series_format(SeriesFormat *format) { + format->digits_before = std::max(format->digits_before, 1); + format->decimals = std::clamp(format->decimals, 0, 6); + format->integer_like = format->decimals == 0; + const int sign_width = format->has_negative ? 1 : 0; + const int dot_width = format->decimals > 0 ? 1 : 0; + format->total_width = sign_width + format->digits_before + dot_width + format->decimals; + std::snprintf(format->fmt, sizeof(format->fmt), "%%%d.%df", format->total_width, format->decimals); +} + +} // namespace + +SeriesFormat compute_series_format(const std::vector &values, bool enum_like) { + SeriesFormat format; + if (values.empty()) return format; + + const size_t step = std::max(1, values.size() / 256); + bool saw_finite = false; + bool all_integer = enum_like; + double min_value = 0.0; + double max_value = 0.0; + int max_needed_decimals = 0; + + for (size_t i = 0; i < values.size(); i += step) { + const double value = values[i]; + if (!std::isfinite(value)) continue; + if (!saw_finite) { + min_value = value; + max_value = value; + saw_finite = true; + } else { + min_value = std::min(min_value, value); + max_value = std::max(max_value, value); + } + if (std::abs(value - std::round(value)) > 1.0e-9) { + all_integer = false; + } + if (!all_integer) { + max_needed_decimals = std::max(max_needed_decimals, decimals_needed(value)); + } + } + + if (!saw_finite) return format; + + format.has_negative = min_value < 0.0; + const double peak = std::max(std::abs(min_value), std::abs(max_value)); + format.digits_before = peak < 1.0 ? 1 : static_cast(std::floor(std::log10(peak))) + 1; + + if (enum_like || all_integer) { + format.decimals = 0; + } else if (peak >= 1000.0) { + format.decimals = std::min(max_needed_decimals, 1); + } else if (peak >= 100.0) { + format.decimals = std::min(max_needed_decimals, 2); + } else { + format.decimals = std::min(max_needed_decimals, 4); + } + + finalize_series_format(&format); + return format; +} + +std::string format_display_value(double display_value, + const SeriesFormat &display_info, + const EnumInfo *enum_info) { + if (!std::isfinite(display_value)) return "---"; + if (enum_info != nullptr) { + const int idx = static_cast(std::llround(display_value)); + if (idx >= 0 && std::abs(display_value - static_cast(idx)) < 0.01 + && static_cast(idx) < enum_info->names.size() + && !enum_info->names[static_cast(idx)].empty()) { + return enum_info->names[static_cast(idx)]; + } + } + char buf[64] = {}; + std::snprintf(buf, sizeof(buf), display_info.fmt, display_value); + return buf; +} + +std::vector decode_browser_drag_payload(std::string_view payload) { + std::vector out; + size_t begin = 0; + while (begin <= payload.size()) { + const size_t end = payload.find('\n', begin); + const size_t length = (end == std::string_view::npos ? payload.size() : end) - begin; + if (length > 0) { + out.emplace_back(payload.substr(begin, length)); + } + if (end == std::string_view::npos) break; + begin = end + 1; + } + return out; +} + +void collect_visible_leaf_paths(const BrowserNode &node, + const std::string &filter, + std::vector *out) { + if (!browser_node_matches(node, filter)) { + return; + } + if (node.children.empty()) { + if (!node.full_path.empty()) { + out->push_back(node.full_path); + } + return; + } + for (const BrowserNode &child : node.children) { + collect_visible_leaf_paths(child, filter, out); + } +} + +void rebuild_browser_nodes(AppSession *session, UiState *state) { + const std::vector paths = visible_browser_paths(session->route_data, state->show_deprecated_fields); + session->browser_nodes = build_browser_tree(paths); + prune_browser_selection(state, paths); +} + +void rebuild_route_index(AppSession *session) { + session->series_by_path.clear(); + session->route_data.series_formats.clear(); + for (RouteSeries &series : session->route_data.series) { + session->series_by_path.emplace(series.path, &series); + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats.emplace(series.path, compute_series_format(series.values, enum_like)); + } +} + +void draw_browser_node(AppSession *session, + const BrowserNode &node, + UiState *state, + const std::string &filter, + const std::vector &visible_paths) { + if (!browser_node_matches(node, filter)) { + return; + } + + if (node.children.empty()) { + const bool selected = browser_selection_contains(*state, node.full_path); + const std::string value_text = browser_series_value_text(*session, *state, node.full_path); + const ImGuiStyle &style = ImGui::GetStyle(); + const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight()); + ImGui::PushID(node.full_path.c_str()); + const bool clicked = ImGui::InvisibleButton("##browser_leaf", row_size); + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + if (selected || hovered) { + const ImU32 bg = ImGui::GetColorU32(selected + ? (held ? ImGuiCol_HeaderActive : ImGuiCol_Header) + : ImGuiCol_HeaderHovered); + draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f); + } + + const float value_right = rect.Max.x - style.FramePadding.x; + const float value_left = value_right - (value_text.empty() ? 0.0f : BROWSER_VALUE_WIDTH); + const float label_left = rect.Min.x + style.FramePadding.x; + const float label_right = value_text.empty() + ? rect.Max.x - style.FramePadding.x + : std::max(label_left + 40.0f, value_left - 10.0f); + ImGui::RenderTextEllipsis(draw_list, + ImVec2(label_left, rect.Min.y + style.FramePadding.y), + ImVec2(label_right, rect.Max.y), + label_right, + node.label.c_str(), + nullptr, + nullptr); + if (!value_text.empty()) { + app_push_mono_font(); + ImGui::PushStyleColor(ImGuiCol_Text, selected ? color_rgb(70, 77, 86) : color_rgb(116, 124, 133)); + ImGui::RenderTextClipped(ImVec2(value_left, rect.Min.y + style.FramePadding.y), + ImVec2(value_right, rect.Max.y), + value_text.c_str(), + nullptr, + nullptr, + ImVec2(1.0f, 0.0f)); + ImGui::PopStyleColor(); + app_pop_mono_font(); + } + + if (clicked) { + const bool shift_down = ImGui::GetIO().KeyShift; + const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; + if (shift_down) { + select_browser_range(state, visible_paths, node.full_path); + } else if (ctrl_down) { + toggle_browser_selection(state, node.full_path); + } else { + set_browser_selection_single(state, node.full_path); + } + } + if (hovered && ImGui::IsMouseDoubleClicked(0)) { + set_browser_selection_single(state, node.full_path); + app_add_curve_to_active_pane(session, state, node.full_path); + } + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + const std::vector drag_paths = browser_drag_paths(*state, node.full_path); + const std::string payload = encode_browser_drag_payload(drag_paths); + ImGui::SetDragDropPayload("JOTP_BROWSER_PATHS", payload.c_str(), payload.size() + 1); + if (drag_paths.size() == 1) { + ImGui::TextUnformatted(drag_paths.front().c_str()); + } else { + ImGui::Text("%zu timeseries", drag_paths.size()); + ImGui::TextUnformatted(drag_paths.front().c_str()); + } + ImGui::EndDragDropSource(); + } + ImGui::PopID(); + return; + } + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth; + if (!filter.empty()) { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + const bool open = ImGui::TreeNodeEx(node.label.c_str(), flags); + if (open) { + for (const BrowserNode &child : node.children) { + draw_browser_node(session, child, state, filter, visible_paths); + } + ImGui::TreePop(); + } +} diff --git a/tools/jotpluggler/camera.cc b/tools/jotpluggler/camera.cc new file mode 100644 index 000000000..24a35d879 --- /dev/null +++ b/tools/jotpluggler/camera.cc @@ -0,0 +1,54 @@ +#include "tools/jotpluggler/camera.h" + +#include "imgui.h" +#include "imgui_internal.h" + +namespace { + +bool draw_camera_fit_toggle_overlay(bool fit_to_pane) { + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImRect rect(ImVec2(window_pos.x + content_min.x + 8.0f, window_pos.y + content_min.y + 8.0f), + ImVec2(window_pos.x + content_min.x + 58.0f, window_pos.y + content_min.y + 28.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(); + draw_list->AddRectFilled(rect.Min, rect.Max, hovered ? IM_COL32(255, 255, 255, 234) : IM_COL32(255, 255, 255, 214), 4.0f); + draw_list->AddRect(rect.Min, rect.Max, IM_COL32(184, 189, 196, 255), 4.0f, 0, 1.0f); + const ImRect box(ImVec2(rect.Min.x + 6.0f, rect.Min.y + 4.0f), ImVec2(rect.Min.x + 18.0f, rect.Min.y + 16.0f)); + draw_list->AddRect(box.Min, box.Max, IM_COL32(112, 120, 129, 255), 2.0f, 0, 1.0f); + if (fit_to_pane) { + draw_list->AddLine(ImVec2(box.Min.x + 2.5f, box.Min.y + 6.5f), ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), IM_COL32(60, 111, 202, 255), 1.8f); + draw_list->AddLine(ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), ImVec2(box.Max.x - 2.5f, box.Min.y + 2.5f), IM_COL32(60, 111, 202, 255), 1.8f); + } + draw_list->AddText(ImVec2(box.Max.x + 6.0f, rect.Min.y + 3.0f), IM_COL32(72, 79, 88, 255), "Fit"); + return hovered && !held && ImGui::IsMouseReleased(ImGuiMouseButton_Left); +} + +} // namespace + +void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane) { + CameraFeedView *feed = session->pane_camera_feeds[static_cast(pane.camera_view)].get(); + if (feed == nullptr) { + ImGui::TextDisabled("Camera unavailable"); + return; + } + + const bool fit_to_pane = tab_state != nullptr + && pane_index >= 0 + && pane_index < static_cast(tab_state->camera_panes.size()) + ? tab_state->camera_panes[static_cast(pane_index)].fit_to_pane + : true; + if (state->has_tracker_time) { + feed->update(state->tracker_time); + } + feed->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading, fit_to_pane); + if (tab_state != nullptr + && pane_index >= 0 + && pane_index < static_cast(tab_state->camera_panes.size()) + && draw_camera_fit_toggle_overlay(fit_to_pane)) { + tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = !fit_to_pane; + } +} diff --git a/tools/jotpluggler/camera.h b/tools/jotpluggler/camera.h new file mode 100644 index 000000000..666e335af --- /dev/null +++ b/tools/jotpluggler/camera.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane); diff --git a/tools/jotpluggler/common.cc b/tools/jotpluggler/common.cc new file mode 100644 index 000000000..9bd6c18ce --- /dev/null +++ b/tools/jotpluggler/common.cc @@ -0,0 +1,179 @@ +#include "tools/jotpluggler/common.h" + +#include +#include +#include + +namespace { + +std::string format_coord(const GpsPoint &point) { + return util::string_format("%.5f,%.5f", point.lat, point.lon); +} + +} // namespace + +const CameraViewSpec &camera_view_spec(CameraViewKind view) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return spec.view == view; + }); + return it != kCameraViewSpecs.end() ? *it : kCameraViewSpecs.front(); +} + +const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return item_id == spec.special_item_id; + }); + return it != kCameraViewSpecs.end() ? &*it : nullptr; +} + +const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return layout_name == spec.layout_name; + }); + return it != kCameraViewSpecs.end() ? &*it : nullptr; +} + +const SpecialItemSpec *special_item_spec(std::string_view item_id) { + auto it = std::find_if(kSpecialItemSpecs.begin(), kSpecialItemSpecs.end(), [&](const SpecialItemSpec &spec) { + return item_id == spec.id; + }); + return it != kSpecialItemSpecs.end() ? &*it : nullptr; +} + +const char *special_item_label(std::string_view item_id) { + const SpecialItemSpec *spec = special_item_spec(item_id); + return spec != nullptr ? spec->label : "Item"; +} + +bool pane_kind_is_special(PaneKind kind) { + return kind == PaneKind::Map || kind == PaneKind::Camera; +} + +bool is_default_special_title(std::string_view title) { + if (title == "Map") return true; + return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return title == spec.label; + }); +} + +CameraViewKind sidebar_preview_camera_view(const AppSession &session) { + return session.route_data.road_camera.entries.empty() && !session.route_data.qroad_camera.entries.empty() + ? CameraViewKind::QRoad + : CameraViewKind::Road; +} + +const std::filesystem::path &repo_root() { + static const std::filesystem::path root(JOTP_REPO_ROOT); + return root; +} + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha) { + return timeline_entry_color(type, alpha, {111, 143, 175}); +} + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array none_color) { + switch (type) { + case TimelineEntry::Type::Engaged: + return ImGui::GetColorU32(color_rgb(0, 163, 108, alpha)); + case TimelineEntry::Type::AlertInfo: + return ImGui::GetColorU32(color_rgb(255, 195, 0, alpha)); + case TimelineEntry::Type::AlertWarning: + case TimelineEntry::Type::AlertCritical: + return ImGui::GetColorU32(color_rgb(199, 0, 57, alpha)); + case TimelineEntry::Type::None: + default: + return ImGui::GetColorU32(color_rgb(none_color, alpha)); + } +} + +const char *timeline_entry_label(TimelineEntry::Type type) { + static constexpr const char *kLabels[] = { + "disengaged", + "engaged", + "alert info", + "alert warning", + "alert critical", + }; + const size_t index = static_cast(type); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +TimelineEntry::Type timeline_type_at_time(const std::vector &timeline, double time_value) { + for (const TimelineEntry &entry : timeline) { + if (time_value >= entry.start_time && time_value <= entry.end_time) { + return entry.type; + } + } + return TimelineEntry::Type::None; +} + +std::string normalize_stream_address(std::string address) { + return is_local_stream_address(address) ? "127.0.0.1" : address; +} + +const char *stream_source_kind_label(StreamSourceKind kind) { + static constexpr const char *kLabels[] = { + "Local (MSGQ)", + "Remote (ZMQ)", + }; + const size_t index = static_cast(kind); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +std::string stream_source_target_label(const StreamSourceConfig &source) { + switch (source.kind) { + case StreamSourceKind::CerealRemote: + return normalize_stream_address(source.address); + case StreamSourceKind::CerealLocal: + default: + return "127.0.0.1"; + } +} + +bool env_flag_enabled(const char *name, bool default_value) { + const char *raw = std::getenv(name); + if (raw == nullptr || raw[0] == '\0') { + return default_value; + } + const std::string value = lowercase_copy(util::strip(raw)); + return !(value == "0" || value == "false" || value == "no" || value == "off"); +} + +void open_external_url(std::string_view url) { +#ifdef __APPLE__ + const std::string command = "open " + shell_quote(url) + " &"; +#else + const std::string command = "xdg-open " + shell_quote(url) + " >/dev/null 2>&1 &"; +#endif + util::check_system(command); +} + +std::string route_useradmin_url(const RouteIdentifier &route_id) { + return route_id.empty() ? std::string() + : "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id; +} + +std::string route_connect_url(const RouteIdentifier &route_id) { + return route_id.empty() ? std::string() + : "https://connect.comma.ai/" + route_id.canonical(); +} + +std::string route_google_maps_url(const GpsTrace &trace) { + if (trace.points.size() < 2) { + return {}; + } + + const std::string prefix = "https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=" + + format_coord(trace.points.front()) + "&destination=" + format_coord(trace.points.back()); + for (size_t n = std::min(9, trace.points.size() > 2 ? trace.points.size() - 2 : 0); ; --n) { + std::string url = prefix; + if (n > 0) { + url += "&waypoints="; + for (size_t i = 0; i < n; ++i) { + if (i) url += "%7C"; + url += format_coord(trace.points[1 + ((trace.points.size() - 2) * (i + 1)) / (n + 1)]); + } + } + if (url.size() <= 1900 || n == 0) return url; + } +} diff --git a/tools/jotpluggler/common.h b/tools/jotpluggler/common.h new file mode 100644 index 000000000..25b1f91e8 --- /dev/null +++ b/tools/jotpluggler/common.h @@ -0,0 +1,63 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +#include +#include + +struct CameraViewSpec { + CameraViewKind view = CameraViewKind::Road; + const char *label = ""; + const char *runtime_name = ""; + const char *layout_name = ""; + const char *special_item_id = ""; + CameraFeedIndex RouteData::*route_member = nullptr; +}; + +struct SpecialItemSpec { + const char *id = ""; + const char *label = ""; + PaneKind kind = PaneKind::Plot; + CameraViewKind camera_view = CameraViewKind::Road; +}; + +inline constexpr std::array kCameraViewSpecs = {{ + {CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera}, + {CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera}, + {CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera}, + {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, +}}; + +inline constexpr std::array kSpecialItemSpecs = {{ + {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, + {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, + {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, + {kCameraViewSpecs[3].special_item_id, kCameraViewSpecs[3].label, PaneKind::Camera, kCameraViewSpecs[3].view}, +}}; + +const CameraViewSpec &camera_view_spec(CameraViewKind view); +const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id); +const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name); + +const SpecialItemSpec *special_item_spec(std::string_view item_id); +const char *special_item_label(std::string_view item_id); + +bool pane_kind_is_special(PaneKind kind); +bool is_default_special_title(std::string_view title); +CameraViewKind sidebar_preview_camera_view(const AppSession &session); +const std::filesystem::path &repo_root(); + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha = 1.0f); +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array none_color); +const char *timeline_entry_label(TimelineEntry::Type type); +TimelineEntry::Type timeline_type_at_time(const std::vector &timeline, double time_value); +std::string normalize_stream_address(std::string address); +const char *stream_source_kind_label(StreamSourceKind kind); +std::string stream_source_target_label(const StreamSourceConfig &source); + +bool env_flag_enabled(const char *name, bool default_value = false); +void open_external_url(std::string_view url); +std::string route_useradmin_url(const RouteIdentifier &route_id); +std::string route_connect_url(const RouteIdentifier &route_id); +std::string route_google_maps_url(const GpsTrace &trace); diff --git a/tools/jotpluggler/custom_series.cc b/tools/jotpluggler/custom_series.cc new file mode 100644 index 000000000..bd2a3f36d --- /dev/null +++ b/tools/jotpluggler/custom_series.cc @@ -0,0 +1,750 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include "implot.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +struct PythonEvalResult { + std::vector xs; + std::vector ys; +}; + +struct CustomSeriesTemplate { + const char *name; + const char *globals_code; + const char *function_code; + const char *preview_text; + int required_additional_sources; + const char *requirement_text; +}; + +void write_binary_vector(const fs::path &path, const std::vector &values) { + write_file_or_throw(path, values.data(), values.size() * sizeof(double)); +} + +std::vector read_binary_vector(const fs::path &path) { + const std::string raw = read_file_or_throw(path); + if (raw.size() % sizeof(double) != 0) { + throw std::runtime_error("Invalid binary series file: " + path.string()); + } + std::vector values(raw.size() / sizeof(double)); + if (!values.empty()) { + std::memcpy(values.data(), raw.data(), raw.size()); + } + return values; +} + +void write_text_file(const fs::path &path, std::string_view text) { + write_file_or_throw(path, text); +} + +fs::path create_custom_series_temp_dir() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp)); + fs::create_directories(dir); + return dir; +} + +void reset_custom_series_editor(CustomSeriesEditorState *editor) { + *editor = CustomSeriesEditorState{}; +} + +bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) { + if (path.empty() || path == editor->linked_source) return false; + if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) { + return false; + } + editor->additional_sources.push_back(path); + return true; +} + +std::string next_custom_curve_name(const Pane &pane) { + std::set used; + for (const Curve &curve : pane.curves) { + if (!curve.label.empty()) { + used.insert(curve.label); + } + if (!curve.name.empty()) { + used.insert(curve.name); + } + } + for (int i = 1; i < 1000; ++i) { + const std::string candidate = "series" + std::to_string(i); + if (used.find(candidate) == used.end()) { + return candidate; + } + } + return "series"; +} + +Curve make_custom_curve(const Pane &pane, + const std::string &name, + const CustomPythonSeries &spec, + PythonEvalResult result) { + Curve curve; + curve.name = name; + curve.label = name; + curve.color = app_next_curve_color(pane); + curve.runtime_only = true; + curve.custom_python = spec; + curve.xs = std::move(result.xs); + curve.ys = std::move(result.ys); + return curve; +} + +bool upsert_custom_curve_in_pane(WorkspaceTab *tab, int pane_index, Curve curve) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + for (Curve &existing : pane.curves) { + if (existing.runtime_only && existing.name == curve.name) { + existing.visible = true; + existing.label = curve.label; + existing.custom_python = curve.custom_python; + existing.xs = std::move(curve.xs); + existing.ys = std::move(curve.ys); + return false; + } + } + pane.curves.push_back(std::move(curve)); + return true; +} + +std::set collect_custom_series_paths(const CustomPythonSeries &spec, + std::string_view globals_code, + std::string_view function_code) { + std::set paths; + if (!spec.linked_source.empty()) { + paths.insert(spec.linked_source); + } + paths.insert(spec.additional_sources.begin(), spec.additional_sources.end()); + + static const std::regex kPathRegex(R"([tv]\(\s*["']([^"']+)["']\s*\))"); + const auto collect_from = [&](std::string_view code) { + std::string owned(code); + for (std::sregex_iterator it(owned.begin(), owned.end(), kPathRegex), end; it != end; ++it) { + paths.insert((*it)[1].str()); + } + }; + collect_from(globals_code); + collect_from(function_code); + return paths; +} + +PythonEvalResult evaluate_custom_python_series(const AppSession &session, + const CustomPythonSeries &spec) { + const std::set referenced_paths = + collect_custom_series_paths(spec, spec.globals_code, spec.function_code); + if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code."); + + const fs::path temp_dir = create_custom_series_temp_dir(); + try { + const fs::path globals_path = temp_dir / "globals.py"; + const fs::path code_path = temp_dir / "code.py"; + const fs::path manifest_path = temp_dir / "manifest.json"; + const fs::path out_t_path = temp_dir / "result.t.bin"; + const fs::path out_v_path = temp_dir / "result.v.bin"; + + write_text_file(globals_path, spec.globals_code); + write_text_file(code_path, spec.function_code); + + json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end()); + json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end()); + json11::Json::array series_json; + size_t series_index = 0; + for (const std::string &path : referenced_paths) { + const RouteSeries *series = app_find_route_series(session, path); + if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { + throw std::runtime_error("Missing route series " + path); + } + const std::string prefix = "series_" + std::to_string(series_index++); + const fs::path time_path = temp_dir / (prefix + ".t.bin"); + const fs::path value_path = temp_dir / (prefix + ".v.bin"); + write_binary_vector(time_path, series->times); + write_binary_vector(value_path, series->values); + series_json.push_back(json11::Json::object{ + {"path", path}, {"t", time_path.string()}, {"v", value_path.string()}}); + } + const json11::Json manifest_json = json11::Json::object{ + {"paths", std::move(paths_json)}, + {"linked_source", spec.linked_source}, + {"additional_sources", std::move(additional_json)}, + {"series", std::move(series_json)}, + }; + write_text_file(manifest_path, manifest_json.dump()); + + const CommandResult process = run_process_capture_output({ + "python3", + (repo_root() / "tools" / "jotpluggler" / "math_eval.py").string(), + manifest_path.string(), + globals_path.string(), + code_path.string(), + out_t_path.string(), + out_v_path.string(), + }); + if (process.exit_code != 0) { + const std::string error_text = util::strip(process.output); + throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text); + } + + PythonEvalResult result; + result.xs = read_binary_vector(out_t_path); + result.ys = read_binary_vector(out_v_path); + if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) { + throw std::runtime_error("Custom series returned invalid output"); + } + fs::remove_all(temp_dir); + return result; + } catch (...) { + std::error_code ignore_error; + fs::remove_all(temp_dir, ignore_error); + throw; + } +} + +void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) { + if (!curve->custom_python.has_value()) { + return; + } + if (!session->route_data.has_time_range || session->route_data.series.empty()) { + curve->runtime_error_message.clear(); + curve->xs.clear(); + curve->ys.clear(); + return; + } + try { + PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python); + curve->runtime_error_message.clear(); + curve->xs = std::move(result.xs); + curve->ys = std::move(result.ys); + } catch (const std::exception &err) { + curve->xs.clear(); + curve->ys.clear(); + const std::string err_text = err.what(); + if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) { + curve->runtime_error_message = err_text; + return; + } + const std::string error_message = std::string("Failed to evaluate custom series \"") + + app_curve_display_name(*curve) + "\":\n\n" + err_text; + if (curve->runtime_error_message != error_message) { + curve->runtime_error_message = error_message; + state->error_text = error_message; + state->open_error_popup = true; + } + } +} + +const std::array &custom_series_templates() { + static constexpr std::array kTemplates = {{ + { + .name = "Derivative", + .globals_code = "", + .function_code = "return np.gradient(value, time)", + .preview_text = "return np.gradient(value, time)", + .required_additional_sources = 0, + .requirement_text = "", + }, + { + .name = "Difference", + .globals_code = "", + .function_code = "return value - v1", + .preview_text = "Requires one additional source timeseries.\n\nreturn value - v1", + .required_additional_sources = 1, + .requirement_text = "Difference requires one additional source timeseries for v1.", + }, + { + .name = "Smoothing", + .globals_code = "window = 20\nweights = np.ones(window) / window", + .function_code = "return np.convolve(value, weights, mode='same')", + .preview_text = "window = 20\nweights = np.ones(window) / window\n\nreturn np.convolve(value, weights, mode='same')", + .required_additional_sources = 0, + .requirement_text = "", + }, + { + .name = "Integral", + .globals_code = "", + .function_code = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", + .preview_text = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", + .required_additional_sources = 0, + .requirement_text = "", + }, + }}; + return kTemplates; +} + +void draw_custom_series_help_popup(CustomSeriesEditorState *editor) { + if (editor->open_help) { + ImGui::OpenPopup("Custom Series Help"); + editor->open_help = false; + } + if (!ImGui::BeginPopupModal("Custom Series Help", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Available variables"); + ImGui::Separator(); + ImGui::BulletText("np: numpy"); + ImGui::BulletText("t(path), v(path): timestamps and values for a route series"); + ImGui::BulletText("paths: all available route series paths"); + ImGui::BulletText("time, value: linked input timeseries"); + ImGui::BulletText("t1, v1, t2, v2, ...: additional source timeseries"); + ImGui::Spacing(); + ImGui::TextWrapped("Write either a single expression like \"return np.gradient(value, time)\" " + "or a multi-line Python body that returns an array or a (times, values) tuple."); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_custom_series_preview(const AppSession &session, CustomSeriesEditorState *editor) { + std::vector preview_xs; + std::vector preview_ys; + std::string preview_label = editor->preview_label; + if (editor->preview_is_result && editor->preview_xs.size() > 1 && editor->preview_xs.size() == editor->preview_ys.size()) { + preview_xs = editor->preview_xs; + preview_ys = editor->preview_ys; + if (preview_label.empty()) { + preview_label = "Result preview"; + } + } else if (!editor->linked_source.empty()) { + if (const RouteSeries *series = app_find_route_series(session, editor->linked_source); series != nullptr + && series->times.size() > 1 && series->times.size() == series->values.size()) { + preview_xs = series->times; + preview_ys = series->values; + preview_label = "Input preview (not result)"; + } + } + + if (!preview_xs.empty() && preview_xs.size() == preview_ys.size()) { + std::vector plot_xs; + std::vector plot_ys; + app_decimate_samples(preview_xs, preview_ys, 1200, &plot_xs, &plot_ys); + const double preview_x_min = preview_xs.front(); + const double preview_x_max = preview_xs.back() > preview_xs.front() + ? preview_xs.back() + : preview_xs.front() + 1e-6; + std::string plot_id = "##custom_series_preview"; + if (editor->preview_is_result) { + plot_id += "_result_"; + plot_id += editor->name.empty() ? preview_label : editor->name; + } else if (!editor->linked_source.empty()) { + plot_id += "_input_"; + plot_id += editor->linked_source; + } + ImGui::TextUnformatted(preview_label.c_str()); + if (!editor->linked_source.empty() && !editor->preview_is_result) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", editor->linked_source.c_str()); + } + if (ImPlot::BeginPlot(plot_id.c_str(), + ImVec2(-1.0f, std::max(180.0f, ImGui::GetContentRegionAvail().y - 6.0f)), + ImPlotFlags_NoTitle | ImPlotFlags_NoMenus | ImPlotFlags_NoLegend)) { + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight, + ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit); + ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, preview_x_min, preview_x_max); + ImPlot::SetupAxisLimits(ImAxis_X1, preview_x_min, preview_x_max, ImPlotCond_Once); + ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); + ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g"); + ImPlotSpec spec; + spec.LineColor = color_rgb(35, 107, 180); + spec.LineWeight = 2.0f; + ImPlot::PlotLine("##custom_preview_line", plot_xs.data(), plot_ys.data(), static_cast(plot_xs.size()), spec); + ImPlot::EndPlot(); + } + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133)); + ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result."); + ImGui::PopStyleColor(); + } +} + +std::string custom_series_name_status(const Pane &pane, std::string_view name) { + const std::string trimmed = util::strip(std::string(name)); + if (trimmed.empty()) return "name required"; + if (!trimmed.empty() && trimmed.front() == '/') { + return "cannot start with /"; + } + for (const Curve &curve : pane.curves) { + if (curve.runtime_only && curve.name == trimmed) return "updates existing curve"; + } + return "new curve"; +} + +const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) { + const auto &templates = custom_series_templates(); + return templates[static_cast(std::clamp(editor.selected_template, 0, static_cast(templates.size()) - 1))]; +} + +bool custom_series_template_ready(const CustomSeriesEditorState &editor) { + const CustomSeriesTemplate &templ = selected_custom_series_template(editor); + return !editor.linked_source.empty() + && static_cast(editor.additional_sources.size()) >= templ.required_additional_sources; +} + +bool prepare_custom_series_spec(CustomSeriesEditorState *editor, + UiState *state, + bool require_name, + CustomPythonSeries *out_spec) { + editor->name = util::strip(editor->name); + editor->linked_source = util::strip(editor->linked_source); + for (std::string &path : editor->additional_sources) { + path = util::strip(path); + } + editor->additional_sources.erase( + std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(), + [&](const std::string &path) { return path.empty() || path == editor->linked_source; }), + editor->additional_sources.end()); + + if (require_name && editor->name.empty()) { + state->error_text = "Custom series name is required."; + state->open_error_popup = true; + return false; + } + if (require_name && !editor->name.empty() && editor->name.front() == '/') { + state->error_text = "Custom series names may not start with '/'."; + state->open_error_popup = true; + return false; + } + + *out_spec = CustomPythonSeries{ + .linked_source = editor->linked_source, + .additional_sources = editor->additional_sources, + .globals_code = editor->globals_code, + .function_code = editor->function_code, + }; + return true; +} + +bool preview_custom_series_editor(AppSession *session, UiState *state) { + CustomSeriesEditorState &editor = state->custom_series; + const CustomSeriesTemplate &templ = selected_custom_series_template(editor); + if (editor.linked_source.empty()) { + state->error_text = "Choose an input timeseries before previewing."; + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } + if (static_cast(editor.additional_sources.size()) < templ.required_additional_sources) { + state->error_text = templ.requirement_text; + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } + CustomPythonSeries spec; + if (!prepare_custom_series_spec(&editor, state, false, &spec)) return false; + + try { + PythonEvalResult result = evaluate_custom_python_series(*session, spec); + editor.preview_label = editor.name.empty() ? "Result preview" : editor.name; + editor.preview_xs = std::move(result.xs); + editor.preview_ys = std::move(result.ys); + editor.preview_is_result = true; + state->status_text = "Previewed custom series"; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } +} + +bool apply_custom_series_editor(AppSession *session, UiState *state) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) { + state->status_text = "No active pane"; + return false; + } + if (tab_state->active_pane_index < 0 || tab_state->active_pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return false; + } + + CustomSeriesEditorState &editor = state->custom_series; + CustomPythonSeries spec; + if (!prepare_custom_series_spec(&editor, state, true, &spec)) return false; + + try { + PythonEvalResult result = evaluate_custom_python_series(*session, spec); + const SketchLayout before_layout = session->layout; + Pane &pane = tab->panes[static_cast(tab_state->active_pane_index)]; + editor.preview_label = editor.name; + editor.preview_xs = result.xs; + editor.preview_ys = result.ys; + editor.preview_is_result = true; + const bool inserted = upsert_custom_curve_in_pane(tab, + tab_state->active_pane_index, + make_custom_curve(pane, editor.name, spec, std::move(result))); + state->undo.push(before_layout); + state->status_text = inserted ? "Created custom series " + editor.name + : "Updated custom series " + editor.name; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Custom series failed"; + return false; + } +} + +} // namespace + +void open_custom_series_editor(UiState *state, const std::string &preferred_source) { + CustomSeriesEditorState &editor = state->custom_series; + if (!editor.open && editor.name.empty() && editor.linked_source.empty() && editor.function_code == "return value") { + editor.focus_name = true; + } + if (editor.linked_source.empty() && !preferred_source.empty()) { + editor.linked_source = preferred_source; + } + editor.open = true; + editor.request_select = true; +} + +std::string preferred_custom_series_source(const Pane &pane) { + for (const Curve &curve : pane.curves) { + if (!curve.name.empty() && curve.name.front() == '/') { + return curve.name; + } + if (curve.custom_python.has_value() && !curve.custom_python->linked_source.empty()) { + return curve.custom_python->linked_source; + } + } + return {}; +} + +void refresh_all_custom_curves(AppSession *session, UiState *state) { + for (WorkspaceTab &tab : session->layout.tabs) { + for (Pane &pane : tab.panes) { + for (Curve &curve : pane.curves) { + refresh_custom_curve_samples(session, state, &curve); + } + } + } +} + +void draw_editor_source_panel(UiState *state, CustomSeriesEditorState &editor) { + ImGui::TextWrapped("Input timeseries. Provides arguments time and value:"); + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_string("##custom_linked_source", &editor.linked_source, ImGuiInputTextFlags_ReadOnly); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { + editor.linked_source = static_cast(payload->Data); + editor.additional_sources.erase( + std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), + editor.additional_sources.end()); + editor.preview_is_result = false; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::Button("Use Selected", ImVec2(120.0f, 0.0f)) && !state->selected_browser_path.empty()) { + editor.linked_source = state->selected_browser_path; + editor.additional_sources.erase( + std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), + editor.additional_sources.end()); + editor.preview_is_result = false; + } + ImGui::SameLine(); + if (ImGui::Button("Clear", ImVec2(120.0f, 0.0f))) { + editor.linked_source.clear(); + editor.preview_is_result = false; + } + + ImGui::Spacing(); + ImGui::TextUnformatted("Additional source timeseries:"); + ImGui::SameLine(); + const CustomSeriesTemplate &tmpl = selected_custom_series_template(editor); + if (tmpl.required_additional_sources > 0) { + const bool ready = static_cast(editor.additional_sources.size()) >= tmpl.required_additional_sources; + ImGui::TextColored(ready ? color_rgb(58, 126, 73) : color_rgb(180, 122, 44), "%s", tmpl.requirement_text); + } + ImGui::SameLine(); + ImGui::BeginDisabled(editor.selected_additional_source < 0 + || editor.selected_additional_source >= static_cast(editor.additional_sources.size())); + if (ImGui::Button("Remove Selected", ImVec2(140.0f, 0.0f)) + && editor.selected_additional_source >= 0 + && editor.selected_additional_source < static_cast(editor.additional_sources.size())) { + editor.additional_sources.erase(editor.additional_sources.begin() + + static_cast(editor.selected_additional_source)); + editor.selected_additional_source = editor.additional_sources.empty() + ? -1 : std::clamp(editor.selected_additional_source, 0, static_cast(editor.additional_sources.size()) - 1); + editor.preview_is_result = false; + } + ImGui::EndDisabled(); + + if (ImGui::BeginChild("##custom_additional_sources", ImVec2(0.0f, 156.0f), true)) { + if (ImGui::BeginTable("##custom_additional_table", 2, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 42.0f); + ImGui::TableSetupColumn("path", ImGuiTableColumnFlags_WidthStretch); + for (size_t i = 0; i < editor.additional_sources.size(); ++i) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("v%zu", i + 1); + ImGui::TableNextColumn(); + if (ImGui::Selectable(editor.additional_sources[i].c_str(), + editor.selected_additional_source == static_cast(i), + ImGuiSelectableFlags_SpanAllColumns)) { + editor.selected_additional_source = static_cast(i); + } + } + ImGui::EndTable(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { + if (add_additional_source(&editor, static_cast(payload->Data))) + editor.preview_is_result = false; + } + ImGui::EndDragDropTarget(); + } + } + ImGui::EndChild(); + if (ImGui::Button("Add Selected", ImVec2(120.0f, 0.0f))) { + for (const std::string &path : state->selected_browser_paths) { + if (add_additional_source(&editor, path)) editor.preview_is_result = false; + } + } + + ImGui::Spacing(); + ImGui::SeparatorText("Function library"); + const auto &templates = custom_series_templates(); + if (ImGui::BeginChild("##custom_series_template_list", ImVec2(0.0f, 132.0f), true)) { + for (size_t i = 0; i < templates.size(); ++i) { + if (ImGui::Selectable(templates[i].name, editor.selected_template == static_cast(i), + ImGuiSelectableFlags_AllowDoubleClick)) { + editor.selected_template = static_cast(i); + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + editor.globals_code = templates[i].globals_code; + editor.function_code = templates[i].function_code; + editor.preview_is_result = false; + } + } + } + } + ImGui::EndChild(); + if (ImGui::Button("Use Selected Example")) { + const auto &sel = selected_custom_series_template(editor); + editor.globals_code = sel.globals_code; + editor.function_code = sel.function_code; + editor.preview_is_result = false; + } + ImGui::Spacing(); + ImGui::TextDisabled("Preview"); + ImGui::BeginChild("##custom_series_template_preview", ImVec2(0.0f, 0.0f), true); + ImGui::TextUnformatted(selected_custom_series_template(editor).preview_text); + ImGui::EndChild(); +} + +void draw_editor_code_panel(CustomSeriesEditorState &editor, const Pane *active_pane) { + const std::string name_status = active_pane != nullptr ? custom_series_name_status(*active_pane, editor.name) : "no active pane"; + ImGui::TextUnformatted("New name:"); + ImGui::SameLine(); + const bool name_error = name_status == "name required" || name_status == "cannot start with /"; + ImGui::TextColored(name_error ? color_rgb(200, 72, 64) : color_rgb(58, 126, 73), "%s", name_status.c_str()); + if (editor.focus_name) { ImGui::SetKeyboardFocusHere(); editor.focus_name = false; } + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_string("##custom_series_name", &editor.name, ImGuiInputTextFlags_AutoSelectAll); + + ImGui::Spacing(); + ImGui::SeparatorText("Global variables"); + ImGui::SameLine(); + if (ImGui::SmallButton("Help")) editor.open_help = true; + const float globals_h = std::max(96.0f, ImGui::GetContentRegionAvail().y * 0.28f); + if (input_text_multiline_string("##custom_series_globals", &editor.globals_code, + ImVec2(-FLT_MIN, globals_h), ImGuiInputTextFlags_AllowTabInput)) + editor.preview_is_result = false; + + ImGui::Spacing(); + ImGui::TextUnformatted("def calc(time, value):"); + const float func_h = std::max(180.0f, ImGui::GetContentRegionAvail().y - 16.0f); + if (input_text_multiline_string("##custom_series_function", &editor.function_code, + ImVec2(-FLT_MIN, func_h), ImGuiInputTextFlags_AllowTabInput)) + editor.preview_is_result = false; +} + +void draw_custom_series_editor(AppSession *session, UiState *state) { + CustomSeriesEditorState &editor = state->custom_series; + if (!editor.open) return; + + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + Pane *active_pane = (tab && tab_state && tab_state->active_pane_index >= 0 + && tab_state->active_pane_index < static_cast(tab->panes.size())) + ? &tab->panes[static_cast(tab_state->active_pane_index)] : nullptr; + if (editor.focus_name && active_pane && editor.name.empty()) + editor.name = next_custom_curve_name(*active_pane); + + draw_custom_series_help_popup(&editor); + + if (ImGui::BeginTabBar("##custom_series_tabs")) { + if (ImGui::BeginTabItem("Single Function")) { + const float footer_height = ImGui::GetFrameHeightWithSpacing() * 2.0f + 10.0f; + if (ImGui::BeginChild("##custom_series_body", + ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height)), false)) { + if (ImGui::BeginChild("##custom_series_preview_child", + ImVec2(0.0f, std::max(200.0f, ImGui::GetContentRegionAvail().y * 0.28f)), true)) + draw_custom_series_preview(*session, &editor); + ImGui::EndChild(); + ImGui::Spacing(); + + if (ImGui::BeginTable("##custom_series_editor_table", 2, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp, + ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y)))) { + ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthFixed, 320.0f); + ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##custom_series_left", ImVec2(0.0f, 0.0f), false)) + draw_editor_source_panel(state, editor); + ImGui::EndChild(); + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##custom_series_right", ImVec2(0.0f, 0.0f), false)) + draw_editor_code_panel(editor, active_pane); + ImGui::EndChild(); + ImGui::EndTable(); + } + } + ImGui::EndChild(); + + ImGui::Spacing(); + if (ImGui::Button("New", ImVec2(120.0f, 0.0f))) { + reset_custom_series_editor(&editor); + if (!state->selected_browser_path.empty()) editor.linked_source = state->selected_browser_path; + editor.open = true; + editor.focus_name = true; + } + ImGui::SameLine(); + ImGui::BeginDisabled(!custom_series_template_ready(editor)); + if (ImGui::Button("Preview Result", ImVec2(120.0f, 0.0f))) + preview_custom_series_editor(session, state); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !custom_series_template_ready(editor)) { + if (editor.linked_source.empty()) ImGui::SetTooltip("Choose an input timeseries first."); + else ImGui::SetTooltip("%s", selected_custom_series_template(editor).requirement_text); + } + ImGui::SameLine(); + if (ImGui::Button("Apply", ImVec2(120.0f, 0.0f))) apply_custom_series_editor(session, state); + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.open = false; editor.request_select = false; } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } +} diff --git a/tools/jotpluggler/dbc.h b/tools/jotpluggler/dbc.h new file mode 100644 index 000000000..d7c546150 --- /dev/null +++ b/tools/jotpluggler/dbc.h @@ -0,0 +1,400 @@ +#pragma once + +#include "common/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dbc { + + +struct ValueDescriptionEntry { + double value = 0.0; + std::string text; +}; + +struct Signal { + enum class Type { + Normal = 0, + Multiplexed, + Multiplexor, + }; + + Type type = Type::Normal; + std::string name; + int start_bit = 0; + int msb = 0; + int lsb = 0; + int size = 0; + double factor = 1.0; + double offset = 0.0; + double min = 0.0; + double max = 0.0; + bool is_signed = false; + bool is_little_endian = false; + std::string unit; + std::string comment; + std::string receiver_name; + int multiplex_value = 0; + int multiplexor_index = -1; + std::vector value_descriptions; +}; + +struct Message { + uint32_t address = 0; + std::string name; + uint32_t size = 0; + std::string comment; + std::string transmitter; + std::vector signals; + int multiplexor_index = -1; + + const std::vector &getSignals() const { return signals; } +}; + +class Database { +public: + Database() = default; + explicit Database(const std::filesystem::path &path); + static Database fromContent(const std::string &content, const std::string &filename = ""); + + const Message *message(uint32_t address) const; + const std::unordered_map &messages() const { return messages_; } + std::vector enumNames(const Signal &signal) const; + +private: + void parse(const std::string &content, const std::string &filename); + void parseBo(const std::string &line, int line_number, Message **current_message); + void parseSg(const std::string &line, int line_number, Message *current_message); + void parseVal(const std::string &line, int line_number); + void parseCmBo(const std::string &line, int line_number); + void parseCmSg(const std::string &line, int line_number); + void finalize(); + + std::string filename_; + std::unordered_map messages_; +}; + +void updateMsbLsb(Signal *signal); +double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size); +std::optional signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size); + +namespace { + +std::string unescape_dbc_string(std::string text) { + size_t pos = 0; + while ((pos = text.find("\\\"", pos)) != std::string::npos) { + text.replace(pos, 2, "\""); + ++pos; + } + return text; +} + +int flip_bit_pos(int start_bit) { + return 8 * (start_bit / 8) + 7 - start_bit % 8; +} + +std::string read_multiline_statement(std::istream &stream, std::string statement, int *line_number) { + static const std::regex statement_end(R"(\"\s*;\s*$)"); + while (true) { + const std::string trimmed = util::strip(statement); + if (std::regex_search(trimmed, statement_end)) { + return trimmed; + } + + std::string next_line; + if (!std::getline(stream, next_line)) { + return trimmed; + } + statement += "\n"; + statement += next_line; + ++(*line_number); + } +} + +} // namespace + +inline void updateMsbLsb(Signal *signal) { + if (signal->is_little_endian) { + signal->lsb = signal->start_bit; + signal->msb = signal->start_bit + signal->size - 1; + } else { + signal->lsb = flip_bit_pos(flip_bit_pos(signal->start_bit) + signal->size - 1); + signal->msb = signal->start_bit; + } +} + +inline double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size) { + const int msb_byte = signal.msb / 8; + if (msb_byte >= static_cast(data_size)) return 0.0; + + const int lsb_byte = signal.lsb / 8; + uint64_t val = 0; + if (msb_byte == lsb_byte) { + val = (data[msb_byte] >> (signal.lsb & 7)) & ((1ULL << signal.size) - 1); + } else { + int bits = signal.size; + int i = msb_byte; + const int step = signal.is_little_endian ? -1 : 1; + while (i >= 0 && i < static_cast(data_size) && bits > 0) { + const int msb = (i == msb_byte) ? signal.msb & 7 : 7; + const int lsb = (i == lsb_byte) ? signal.lsb & 7 : 0; + const int nbits = msb - lsb + 1; + val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1)); + bits -= nbits; + i += step; + } + } + + if (signal.is_signed && (val & (1ULL << (signal.size - 1)))) { + val |= ~((1ULL << signal.size) - 1); + } + + return static_cast(val) * signal.factor + signal.offset; +} + +[[noreturn]] inline void parse_error(const std::string &filename, int line_number, const std::string &message, const std::string &line) { + std::ostringstream out; + out << "[" << filename << ":" << line_number << "] " << message << ": " << line; + throw std::runtime_error(out.str()); +} + +inline Database::Database(const std::filesystem::path &path) { + const std::string content = util::read_file(path.string()); + if (content.empty() && !std::filesystem::exists(path)) { + throw std::runtime_error("Failed to open DBC " + path.string()); + } + parse(content, path.filename().string()); +} + +inline Database Database::fromContent(const std::string &content, const std::string &filename) { + Database db; + db.parse(content, filename); + return db; +} + +inline const Message *Database::message(uint32_t address) const { + auto it = messages_.find(address); + return it == messages_.end() ? nullptr : &it->second; +} + +inline std::vector Database::enumNames(const Signal &signal) const { + if (signal.value_descriptions.empty()) return {}; + int max_index = -1; + for (const auto &entry : signal.value_descriptions) { + const double rounded = std::round(entry.value); + if (std::abs(entry.value - rounded) > 1e-6 || rounded < 0.0 || rounded > 512.0) return {}; + max_index = std::max(max_index, static_cast(rounded)); + } + if (max_index < 0) return {}; + std::vector names(static_cast(max_index + 1)); + for (const auto &entry : signal.value_descriptions) { + names[static_cast(std::llround(entry.value))] = entry.text; + } + return names; +} + +inline void Database::parse(const std::string &content, const std::string &filename) { + filename_ = filename; + messages_.clear(); + std::istringstream stream(content); + std::string raw_line; + Message *current_message = nullptr; + int line_number = 0; + while (std::getline(stream, raw_line)) { + ++line_number; + std::string line = util::strip(raw_line); + if (line.empty()) continue; + if (util::starts_with(line, "BO_ ")) { + parseBo(line, line_number, ¤t_message); + } else if (util::starts_with(line, "SG_ ")) { + if (current_message == nullptr) { + parse_error(filename, line_number, "Signal without current message", line); + } + parseSg(line, line_number, current_message); + } else if (util::starts_with(line, "VAL_ ")) { + parseVal(line, line_number); + } else if (util::starts_with(line, "CM_ BO_")) { + parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number); + } else if (util::starts_with(line, "CM_ SG_")) { + parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number); + } + } + finalize(); +} + +inline void Database::parseBo(const std::string &line, int line_number, Message **current_message) { + static const std::regex pattern(R"(^BO_\s+(\w+)\s+(\w+)\s*:\s*(\w+)\s+(\w+)\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error("", line_number, "Invalid BO_ line format", line); + } + uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + if (messages_.find(address) != messages_.end()) { + parse_error(filename_, line_number, "Duplicate message address", line); + } + Message &message = messages_[address]; + message.address = address; + message.name = match[2].str(); + message.size = static_cast(std::stoul(match[3].str(), nullptr, 0)); + message.transmitter = match[4].str(); + message.signals.clear(); + message.multiplexor_index = -1; + *current_message = &message; +} + +inline void Database::parseSg(const std::string &line, int line_number, Message *current_message) { + static const std::regex multiplex_pattern(R"(^SG_\s+(\w+)\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)"); + static const std::regex normal_pattern(R"(^SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)"); + + std::smatch match; + Signal signal; + int offset = 0; + if (std::regex_match(line, match, normal_pattern)) { + offset = 0; + } else if (std::regex_match(line, match, multiplex_pattern)) { + offset = 1; + const std::string indicator = match[2].str(); + if (indicator == "M") { + if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [](const Signal &existing) { + return existing.type == Signal::Type::Multiplexor; + })) { + parse_error(filename_, line_number, "Multiple multiplexor", line); + } + signal.type = Signal::Type::Multiplexor; + } else if (!indicator.empty() && indicator.front() == 'm') { + signal.type = Signal::Type::Multiplexed; + signal.multiplex_value = std::stoi(indicator.substr(1)); + } else { + parse_error("", line_number, "Invalid multiplex indicator", line); + } + } else { + parse_error("", line_number, "Invalid SG_ line format", line); + } + + signal.name = match[1].str(); + if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [&](const Signal &existing) { + return existing.name == signal.name; + })) { + parse_error(filename_, line_number, "Duplicate signal name", line); + } + signal.start_bit = std::stoi(match[2 + offset].str()); + signal.size = std::stoi(match[3 + offset].str()); + signal.is_little_endian = match[4 + offset].str() == "1"; + signal.is_signed = match[5 + offset].str() == "-"; + signal.factor = std::stod(match[6 + offset].str()); + signal.offset = std::stod(match[7 + offset].str()); + signal.min = std::stod(match[8 + offset].str()); + signal.max = std::stod(match[9 + offset].str()); + signal.unit = match[10 + offset].str(); + signal.receiver_name = util::strip(match[11 + offset].str()); + updateMsbLsb(&signal); + current_message->signals.push_back(std::move(signal)); +} + +inline void Database::parseVal(const std::string &line, int line_number) { + static const std::regex prefix(R"(^VAL_\s+(\w+)\s+(\w+)\s+(.*);$)"); + std::smatch match; + if (!std::regex_match(line, match, prefix)) { + parse_error("", line_number, "Invalid VAL_ line format", line); + } + + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto msg_it = messages_.find(address); + if (msg_it == messages_.end()) { + return; + } + auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) { + return signal.name == match[2].str(); + }); + if (sig_it == msg_it->second.signals.end()) { + return; + } + + static const std::regex entry_pattern(R"(([+-]?\d+(?:\.\d+)?)\s+\"((?:[^\"\\]|\\.)*)\")"); + const std::string defs = match[3].str(); + for (std::sregex_iterator it(defs.begin(), defs.end(), entry_pattern), end; it != end; ++it) { + sig_it->value_descriptions.push_back(ValueDescriptionEntry{ + .value = std::stod((*it)[1].str()), + .text = (*it)[2].str(), + }); + } +} + +inline void Database::parseCmBo(const std::string &line, int line_number) { + static const std::regex pattern(R"(^CM_\s+BO_\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error(filename_, line_number, "Invalid message comment format", line); + } + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto it = messages_.find(address); + if (it != messages_.end()) { + it->second.comment = unescape_dbc_string(match[2].str()); + } +} + +inline void Database::parseCmSg(const std::string &line, int line_number) { + static const std::regex pattern(R"(^CM_\s+SG_\s*(\w+)\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error(filename_, line_number, "Invalid signal comment format", line); + } + + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto msg_it = messages_.find(address); + if (msg_it == messages_.end()) return; + + auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) { + return signal.name == match[2].str(); + }); + if (sig_it != msg_it->second.signals.end()) { + sig_it->comment = unescape_dbc_string(match[3].str()); + } +} + +inline void Database::finalize() { + for (auto &[_, message] : messages_) { + std::sort(message.signals.begin(), message.signals.end(), [](const Signal &left, const Signal &right) { + return std::tie(right.type, left.multiplex_value, left.start_bit, left.name) + < std::tie(left.type, right.multiplex_value, right.start_bit, right.name); + }); + message.multiplexor_index = -1; + for (size_t i = 0; i < message.signals.size(); ++i) { + if (message.signals[i].type == Signal::Type::Multiplexor) { + message.multiplexor_index = static_cast(i); + break; + } + } + for (Signal &signal : message.signals) { + signal.multiplexor_index = signal.type == Signal::Type::Multiplexed ? message.multiplexor_index : -1; + if (signal.type == Signal::Type::Multiplexed && signal.multiplexor_index < 0) { + signal.type = Signal::Type::Normal; + signal.multiplex_value = 0; + } + } + } +} + +inline std::optional signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size) { + if (signal.multiplexor_index >= 0) { + const Signal &multiplexor = message.signals[static_cast(signal.multiplexor_index)]; + const double mux_value = rawSignalValue(multiplexor, data, data_size); + if (std::llround(mux_value) != signal.multiplex_value) return std::nullopt; + } + return rawSignalValue(signal, data, data_size); +} + +} // namespace dbc diff --git a/tools/jotpluggler/generated_dbcs/.gitignore b/tools/jotpluggler/generated_dbcs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/tools/jotpluggler/generated_dbcs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tools/jotpluggler/icons.cc b/tools/jotpluggler/icons.cc new file mode 100644 index 000000000..9507090e0 --- /dev/null +++ b/tools/jotpluggler/icons.cc @@ -0,0 +1,24 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include + +void icon_add_font(float size, bool merge, const ImFont *base_font) { + const std::filesystem::path ttf = repo_root() / "third_party" / "bootstrap" / "bootstrap-icons.ttf"; + ImGuiIO &io = ImGui::GetIO(); + ImFontConfig config; + config.MergeMode = merge; + config.GlyphMinAdvanceX = size; + if (base_font != nullptr) { + ImFontBaked *baked = const_cast(base_font)->GetFontBaked(size); + const float base_center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + config.GlyphOffset.y = std::round(size * 0.5f - base_center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + io.Fonts->AddFontFromFileTTF(ttf.c_str(), size, &config, ranges); +} + +bool icon_menu_item(const char *glyph, const char *label, const char *shortcut, bool selected, bool enabled) { + assert(glyph != nullptr && glyph[0] != '\0'); + return ImGui::MenuItem(util::string_format("%s %s", glyph, label).c_str(), shortcut, selected, enabled); +} diff --git a/tools/jotpluggler/internal.h b/tools/jotpluggler/internal.h new file mode 100644 index 000000000..22a5c1dd9 --- /dev/null +++ b/tools/jotpluggler/internal.h @@ -0,0 +1,166 @@ +#pragma once + +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/map.h" + +#include +#include +#include + +struct GLFWwindow; + +enum class PaneDropZone { + Center, + Left, + Right, + Top, + Bottom, +}; + +enum class PaneMenuActionKind { + None, + OpenAxisLimits, + OpenCustomSeries, + SplitLeft, + SplitRight, + SplitTop, + SplitBottom, + ResetView, + ResetHorizontal, + ResetVertical, + Clear, + Close, +}; + +struct PaneMenuAction { + PaneMenuActionKind kind = PaneMenuActionKind::None; + int pane_index = -1; +}; + +struct PaneCurveDragPayload { + int tab_index = -1; + int pane_index = -1; + int curve_index = -1; +}; + +struct PaneDropAction { + PaneDropZone zone = PaneDropZone::Center; + int target_pane_index = -1; + bool from_browser = false; + std::vector browser_paths; + std::string special_item_id; + PaneCurveDragPayload curve_ref; +}; + +inline constexpr float SIDEBAR_WIDTH = 320.0f; +inline constexpr float SIDEBAR_MIN_WIDTH = 220.0f; +inline constexpr float SIDEBAR_MAX_WIDTH = 520.0f; +inline constexpr float TIMELINE_BAR_HEIGHT = 14.0f; +inline constexpr float STATUS_BAR_HEIGHT = 52.0f; +inline constexpr double MIN_HORIZONTAL_ZOOM_SECONDS = 2.0; + +struct UiMetrics { + float width = 0.0f; + float height = 0.0f; + float top_offset = 0.0f; + float sidebar_width = SIDEBAR_WIDTH; + float content_x = 0.0f; + float content_y = 0.0f; + float content_w = 0.0f; + float content_h = 0.0f; + float status_bar_y = 0.0f; +}; + +std::filesystem::path resolve_layout_path(const std::string &layout_arg); +std::filesystem::path autosave_path_for_layout(const std::filesystem::path &layout_path); +std::vector available_layout_names(); + +SketchLayout make_empty_layout(); +void cancel_rename_tab(UiState *state); +void sync_ui_state(UiState *state, const SketchLayout &layout); +void sync_route_buffers(UiState *state, const AppSession &session); +void sync_stream_buffers(UiState *state, const AppSession &session); +void sync_layout_buffers(UiState *state, const AppSession &session); +void mark_all_docks_dirty(UiState *state); +void clear_layout_autosave(const AppSession &session); +bool autosave_layout(AppSession *session, UiState *state); +bool apply_axis_limits_editor(AppSession *session, UiState *state); +void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index); +void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state); +void clear_pane_vertical_limits(Pane *pane); + +void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks); +void start_new_layout(AppSession *session, UiState *state, const std::string &status_text = "New untitled layout"); +void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override); + +void app_push_bold_font(); +void app_pop_bold_font(); +void draw_vertical_splitter(const char *id, float height, float min_left, float max_left, float *left_width); +void draw_right_splitter(const char *id, float height, float min_right, float max_right, float *right_width); +bool draw_horizontal_splitter(const char *id, float width, float min_top, float max_top, float *top_height); +void draw_payload_bytes(std::string_view data, const std::string *prev_data = nullptr); +void draw_payload_preview_boxes(const char *id, std::string_view data, const std::string *prev_data, float max_width); +void draw_signal_sparkline(const AppSession &session, + const UiState &state, + std::string_view signal_path, + bool selected, + ImVec2 size = ImVec2(0.0f, 24.0f)); +ImU32 mix_color(ImU32 a, ImU32 b, float t); +void draw_empty_panel(const char *title, const char *message); + +UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width); +void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed); +void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state); +void draw_pane_windows(AppSession *session, UiState *state); + +// plot.cc +void draw_plot(const AppSession &session, Pane *pane, UiState *state); +bool draw_pane_close_button_overlay(); +void draw_pane_frame_overlay(); +std::optional draw_pane_context_menu(const WorkspaceTab &tab, int pane_index); +bool curve_has_samples(const AppSession &session, const Curve &curve); +bool curve_has_local_samples(const Curve &curve); +std::string app_curve_display_name(const Curve &curve); +bool mark_layout_dirty(AppSession *session, UiState *state); + +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path); +void sync_camera_feeds(AppSession *session); +void apply_route_data(AppSession *session, UiState *state, RouteData route_data); +bool apply_undo(AppSession *session, UiState *state); +bool apply_redo(AppSession *session, UiState *state); +bool infer_stream_follow_state(const UiState &state, const AppSession &session); +void ensure_shared_range(UiState *state, const AppSession &session); +void clamp_shared_range(UiState *state, const AppSession &session); +void reset_shared_range(UiState *state, const AppSession &session); +void update_follow_range(UiState *state, const AppSession &session); +void advance_playback(UiState *state, const AppSession &session); +void step_tracker(UiState *state, double direction); +std::string dbc_combo_label(const AppSession &session); +const char *log_selector_name(LogSelector selector); +const char *log_selector_description(LogSelector selector); +std::string format_cache_bytes(uint64_t bytes); +MapCacheStats directory_cache_stats(const std::filesystem::path &root); +float draw_main_menu_bar(AppSession *session, UiState *state); + +bool reset_layout(AppSession *session, UiState *state); +bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg); +bool save_layout(AppSession *session, UiState *state, const std::string &layout_path); +void rebuild_session_route_data(AppSession *session, UiState *state, + const RouteLoadProgressCallback &progress = {}); +void stop_stream_session(AppSession *session, UiState *state, bool preserve_data = true); +bool start_stream_session(AppSession *session, + UiState *state, + const StreamSourceConfig &source, + double buffer_seconds, + bool preserve_existing_data = false); +void start_async_route_load(AppSession *session, UiState *state); +void poll_async_route_load(AppSession *session, UiState *state); +bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir); +void draw_popups(AppSession *session, UiState *state); + +void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state); +void draw_sidebar_resizer(const UiMetrics &ui, UiState *state); + +void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch); + +void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const std::filesystem::path *capture_path); diff --git a/tools/jotpluggler/layout.cc b/tools/jotpluggler/layout.cc new file mode 100644 index 000000000..8a58ef7cd --- /dev/null +++ b/tools/jotpluggler/layout.cc @@ -0,0 +1,704 @@ +#include "tools/jotpluggler/internal.h" +#include "system/hardware/hw.h" + +#include + +namespace fs = std::filesystem; + +namespace { + +enum class ModalAction { + None, + Primary, + Secondary, +}; + +struct FindSignalMatch { + const std::string *path = nullptr; + int score = 0; +}; + +struct DbcEditorSource { + fs::path path; + DbcEditorState::SourceKind kind = DbcEditorState::SourceKind::None; +}; + +StreamSourceConfig stream_source_config_from_ui(const UiState &state) { + StreamSourceConfig source; + source.kind = state.stream_source_kind; + source.address = util::strip(state.stream_address_buffer); + if (source.kind == StreamSourceKind::CerealLocal) { + source.address = "127.0.0.1"; + } else { + source.address = normalize_stream_address(std::move(source.address)); + } + return source; +} + +void open_queued_popup(bool &flag, const char *name) { + if (flag) { + ImGui::OpenPopup(name); + flag = false; + } +} + +ModalAction draw_modal_action_row(const char *primary_label, + const char *secondary_label = "Cancel", + float width = 120.0f) { + if (ImGui::Button(primary_label, ImVec2(width, 0.0f))) { + return ModalAction::Primary; + } + ImGui::SameLine(); + if (ImGui::Button(secondary_label, ImVec2(width, 0.0f))) { + return ModalAction::Secondary; + } + return ModalAction::None; +} + +std::vector find_signal_matches(const AppSession &session, std::string_view query) { + std::vector matches; + if (query.empty()) { + return matches; + } + const std::string needle = lowercase_copy(query); + for (const std::string &path : session.route_data.paths) { + const std::string hay = lowercase_copy(path); + const size_t pos = hay.find(needle); + if (pos == std::string::npos) { + continue; + } + const size_t slash = path.find_last_of('/'); + const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); + int score = static_cast(pos * 8 + path.size()); + if (lowercase_copy(label) == needle) score -= 60; + if (util::starts_with(hay, needle)) score -= 30; + matches.push_back({.path = &path, .score = score}); + } + std::sort(matches.begin(), matches.end(), [](const FindSignalMatch &a, const FindSignalMatch &b) { + return std::tie(a.score, *a.path) < std::tie(b.score, *b.path); + }); + if (matches.size() > 200) { + matches.resize(200); + } + return matches; +} + +bool open_find_signal_result(UiState *state, const std::string &path) { + state->selected_browser_paths = {path}; + state->selected_browser_path = path; + state->browser_selection_anchor = path; + state->status_text = "Selected signal " + path; + return true; +} + +void draw_open_route_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Open Route", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Load a route into the current layout."); + ImGui::Separator(); + input_text_string("Route", &state->route_buffer); + input_text_string("Data Dir", &state->data_dir_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + reload_session(session, state, state->route_buffer, state->data_dir_buffer); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::Secondary: + sync_route_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_stream_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Live Stream", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + + ImGui::TextUnformatted("Connect to a live source."); + ImGui::Separator(); + if (ImGui::RadioButton("Local (MSGQ)", state->stream_source_kind == StreamSourceKind::CerealLocal)) { + state->stream_source_kind = StreamSourceKind::CerealLocal; + } + if (ImGui::RadioButton("Remote (ZMQ)", state->stream_source_kind == StreamSourceKind::CerealRemote)) { + state->stream_source_kind = StreamSourceKind::CerealRemote; + } + + if (state->stream_source_kind == StreamSourceKind::CerealRemote) { + input_text_string("Address", &state->stream_address_buffer); + } + ImGui::InputDouble("Buffer (seconds)", &state->stream_buffer_seconds, 0.0, 0.0, "%.0f"); + ImGui::Spacing(); + switch (draw_modal_action_row("Connect")) { + case ModalAction::Primary: { + const StreamSourceConfig source = stream_source_config_from_ui(*state); + if (start_stream_session(session, state, source, state->stream_buffer_seconds, false)) { + ImGui::CloseCurrentPopup(); + } + break; + } + case ModalAction::Secondary: + sync_stream_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_load_layout_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Load Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Load a JotPlugger JSON layout."); + ImGui::Separator(); + input_text_string("Layout", &state->load_layout_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + if (reload_layout(session, state, state->load_layout_buffer)) { + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + sync_layout_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_save_layout_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Save Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Save the current workspace as a JotPlugger JSON layout."); + ImGui::Separator(); + input_text_string("Layout", &state->save_layout_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Save")) { + case ModalAction::Primary: + if (save_layout(session, state, state->save_layout_buffer)) { + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + sync_layout_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_preferences_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Preferences", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + if (session->map_data) { + const MapCacheStats map_cache = session->map_data->cacheStats(); + const MapCacheStats download_cache = directory_cache_stats(Path::download_cache_root()); + ImGui::TextUnformatted("Map"); + ImGui::Separator(); + ImGui::Text("Map cache: %s in %zu file%s", + format_cache_bytes(map_cache.bytes).c_str(), + map_cache.files, + map_cache.files == 1 ? "" : "s"); + if (ImGui::Button("Clear Map Cache", ImVec2(120.0f, 0.0f))) { + session->map_data->clearCache(); + state->status_text = "Cleared map cache"; + } + ImGui::Spacing(); + ImGui::TextUnformatted("comma Download Cache"); + ImGui::Separator(); + ImGui::Text("Download cache: %s in %zu file%s", + format_cache_bytes(download_cache.bytes).c_str(), + download_cache.files, + download_cache.files == 1 ? "" : "s"); + ImGui::TextDisabled("%s", Path::download_cache_root().c_str()); + ImGui::Spacing(); + } + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_find_signal_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Find Signal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Search decoded signals across the loaded route."); + ImGui::Separator(); + ImGui::SetNextItemWidth(560.0f); + input_text_with_hint_string("##find_signal_query", "Search signal path or name...", &state->find_signal_buffer); + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(-1); + } + const std::vector matches = find_signal_matches(*session, state->find_signal_buffer); + ImGui::Spacing(); + ImGui::TextDisabled("%zu match%s", matches.size(), matches.size() == 1 ? "" : "es"); + if (ImGui::BeginChild("##find_signal_results", ImVec2(760.0f, 360.0f), true)) { + for (const FindSignalMatch &match : matches) { + const std::string &path = *match.path; + const size_t slash = path.find_last_of('/'); + const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); + if (ImGui::Selectable((std::string(label) + "##" + path).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + if (open_find_signal_result(state, path)) { + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(280.0f); + ImGui::TextDisabled("%s", path.c_str()); + } + } + ImGui::EndChild(); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +std::string default_dbc_template() { + return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n"; +} + +DbcEditorSource resolve_dbc_editor_source(const std::string &dbc_name) { + const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; + const std::array candidates = {{ + {.path = repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Opendbc}, + {.path = generated_dbc_dir / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Generated}, + }}; + for (const DbcEditorSource &candidate : candidates) { + if (fs::exists(candidate.path)) { + return candidate; + } + } + return {}; +} + +void load_dbc_editor_state(const AppSession &session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + const std::string dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name; + editor.source_name = dbc_name.empty() ? "untitled" : dbc_name; + editor.source_path.clear(); + editor.source_kind = DbcEditorState::SourceKind::None; + if (dbc_name.empty()) { + editor.save_name = "custom_can"; + editor.text = default_dbc_template(); + } else { + const DbcEditorSource source = resolve_dbc_editor_source(dbc_name); + editor.source_kind = source.kind; + editor.source_path = source.path; + editor.text = source.path.empty() ? default_dbc_template() : read_file_or_throw(source.path); + editor.save_name = source.kind == DbcEditorState::SourceKind::Generated ? dbc_name : dbc_name + "_edited"; + } + editor.loaded = true; +} + +bool ensure_dbc_editor_loaded(const AppSession &session, UiState *state) { + if (!state->dbc_editor.loaded) { + try { + load_dbc_editor_state(session, state); + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + return false; + } + } + return true; +} + +bool save_dbc_editor_contents(AppSession *session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + editor.save_name = util::strip(editor.save_name); + if (editor.save_name.empty()) { + state->error_text = "DBC name cannot be empty"; + state->open_error_popup = true; + return false; + } + if (editor.source_kind == DbcEditorState::SourceKind::Opendbc && editor.save_name == editor.source_name) { + state->error_text = "Save edited opendbc files under a new name"; + state->open_error_popup = true; + return false; + } + try { + dbc::Database::fromContent(editor.text, editor.save_name + ".dbc"); + const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; + fs::create_directories(generated_dbc_dir); + const fs::path output = generated_dbc_dir / (editor.save_name + ".dbc"); + write_file_or_throw(output, editor.text); + apply_dbc_override_change(session, state, editor.save_name); + editor.source_name = editor.save_name; + editor.source_path = output; + editor.source_kind = DbcEditorState::SourceKind::Generated; + editor.loaded = false; + state->status_text = "Saved DBC " + editor.save_name; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + return false; + } +} + +void draw_dbc_editor_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("DBC Editor", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + DbcEditorState &editor = state->dbc_editor; + if (!ensure_dbc_editor_loaded(*session, state)) { + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return; + } + ImGui::TextUnformatted("Edit DBC text and save it into generated_dbcs."); + ImGui::Separator(); + ImGui::SetNextItemWidth(260.0f); + input_text_string("DBC Name", &editor.save_name, ImGuiInputTextFlags_AutoSelectAll); + if (!editor.source_path.empty()) { + ImGui::TextDisabled("%s", editor.source_path.string().c_str()); + } else { + ImGui::TextDisabled("New in-memory DBC"); + } + ImGui::Spacing(); + input_text_multiline_string("##dbc_editor_text", &editor.text, ImVec2(920.0f, 520.0f), ImGuiInputTextFlags_AllowTabInput); + ImGui::Spacing(); + if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) { + if (save_dbc_editor_contents(session, state)) { + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Reload Source", ImVec2(140.0f, 0.0f))) { + editor.loaded = false; + } + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + editor.loaded = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_axis_limits_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Edit Axis Limits", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + const WorkspaceTab *tab = app_active_tab(session->layout, *state); + const bool valid_pane = tab != nullptr + && state->axis_limits.pane_index >= 0 + && state->axis_limits.pane_index < static_cast(tab->panes.size()); + if (!valid_pane) { + ImGui::TextWrapped("The selected pane is no longer available."); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + + ImGui::TextUnformatted("X range applies to the active tab. Y limits apply to the selected pane."); + ImGui::Separator(); + ImGui::TextUnformatted("Horizontal"); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("X Min", &state->axis_limits.x_min, 0.0, 0.0, "%.3f"); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("X Max", &state->axis_limits.x_max, 0.0, 0.0, "%.3f"); + ImGui::Spacing(); + ImGui::TextUnformatted("Vertical"); + ImGui::Checkbox("Use Y Min", &state->axis_limits.y_min_enabled); + ImGui::BeginDisabled(!state->axis_limits.y_min_enabled); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("Y Min", &state->axis_limits.y_min, 0.0, 0.0, "%.6g"); + ImGui::EndDisabled(); + ImGui::Checkbox("Use Y Max", &state->axis_limits.y_max_enabled); + ImGui::BeginDisabled(!state->axis_limits.y_max_enabled); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("Y Max", &state->axis_limits.y_max, 0.0, 0.0, "%.6g"); + ImGui::EndDisabled(); + ImGui::Spacing(); + switch (draw_modal_action_row("Apply")) { + case ModalAction::Primary: + if (apply_axis_limits_editor(session, state)) { + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_error_popup(UiState *state) { + if (state->open_error_popup) { + ImGui::OpenPopup("Error"); + state->open_error_popup = false; + } + if (!ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextWrapped("%s", state->error_text.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +} // namespace + +bool reset_layout(AppSession *session, UiState *state) { + try { + if (session->layout_path.empty()) { + start_new_layout(session, state, "Reset layout"); + return true; + } + clear_layout_autosave(*session); + session->layout = load_sketch_layout(session->layout_path); + state->layout_dirty = false; + session->autosave_path = autosave_path_for_layout(session->layout_path); + state->undo.reset(session->layout); + refresh_replaced_layout_ui(session, state, false); + reset_shared_range(state, *session); + state->status_text = "Reset layout"; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to reset layout"; + return false; + } +} + +bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg) { + try { + const bool preserve_shared_range = session->route_data.has_time_range && state->has_shared_range; + const double preserved_x_min = state->x_view_min; + const double preserved_x_max = state->x_view_max; + const fs::path layout_path = resolve_layout_path(layout_arg); + session->autosave_path = autosave_path_for_layout(layout_path); + const bool load_draft = fs::exists(session->autosave_path); + session->layout = load_sketch_layout(load_draft ? session->autosave_path : layout_path); + session->layout_path = layout_path; + state->layout_dirty = load_draft; + state->undo.reset(session->layout); + refresh_replaced_layout_ui(session, state, true); + if (preserve_shared_range) { + state->has_shared_range = true; + state->x_view_min = preserved_x_min; + state->x_view_max = preserved_x_max; + clamp_shared_range(state, *session); + } else { + reset_shared_range(state, *session); + } + state->status_text = std::string(load_draft ? "Loaded layout draft " : "Loaded layout ") + + layout_path.filename().string(); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to load layout"; + return false; + } +} + +bool save_layout(AppSession *session, UiState *state, const std::string &layout_path) { + try { + if (layout_path.empty()) throw std::runtime_error("Layout path is empty"); + session->layout.current_tab_index = state->active_tab_index; + const fs::path previous_autosave = session->autosave_path; + const fs::path output = fs::absolute(fs::path(layout_path)); + save_layout_json(session->layout, output); + session->layout_path = output; + session->autosave_path = autosave_path_for_layout(output); + if (!previous_autosave.empty() && previous_autosave != session->autosave_path && fs::exists(previous_autosave)) { + fs::remove(previous_autosave); + } + clear_layout_autosave(*session); + state->layout_dirty = false; + sync_layout_buffers(state, *session); + state->status_text = "Saved layout " + output.filename().string(); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to save layout"; + return false; + } +} + +void rebuild_session_route_data(AppSession *session, UiState *state, + const RouteLoadProgressCallback &progress) { + apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress)); +} + +void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) { + if (preserve_data && session->stream_poller && session->data_mode == SessionDataMode::Stream) { + session->stream_poller->setPaused(true); + } else if (session->stream_poller) { + session->stream_poller->stop(); + } + session->stream_paused = preserve_data && session->data_mode == SessionDataMode::Stream; + if (!preserve_data) { + session->stream_time_offset.reset(); + apply_route_data(session, state, RouteData{}); + } + sync_stream_buffers(state, *session); +} + +bool start_stream_session(AppSession *session, + UiState *state, + const StreamSourceConfig &source, + double buffer_seconds, + bool preserve_existing_data) { + try { + if (session->route_loader) { + session->route_loader.reset(); + } + session->data_mode = SessionDataMode::Stream; + session->route_id = {}; + session->route_name.clear(); + session->data_dir.clear(); + session->stream_source = source; + if (session->stream_source.kind == StreamSourceKind::CerealLocal) { + session->stream_source.address = "127.0.0.1"; + } + session->stream_buffer_seconds = std::max(1.0, buffer_seconds); + session->next_stream_custom_refresh_time = 0.0; + session->stream_paused = false; + if (preserve_existing_data && session->stream_poller) { + StreamPollSnapshot snapshot = session->stream_poller->snapshot(); + if (snapshot.active) { + session->stream_poller->setPaused(false); + sync_route_buffers(state, *session); + sync_stream_buffers(state, *session); + state->follow_latest = true; + state->playback_playing = false; + state->status_text = "Resumed stream " + stream_source_target_label(session->stream_source); + return true; + } + } + if (!preserve_existing_data) { + session->stream_time_offset.reset(); + apply_route_data(session, state, RouteData{}); + } + if (!session->stream_poller) { + session->stream_poller = std::make_unique(); + } + session->stream_poller->start(session->stream_source, + session->stream_buffer_seconds, + session->dbc_override, + session->stream_time_offset); + sync_route_buffers(state, *session); + sync_stream_buffers(state, *session); + state->follow_latest = true; + state->playback_playing = false; + state->status_text = preserve_existing_data ? "Resumed stream " + stream_source_target_label(session->stream_source) + : "Streaming from " + stream_source_target_label(session->stream_source); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to start stream"; + return false; + } +} + +void start_async_route_load(AppSession *session, UiState *state) { + if (!session->route_loader) { + return; + } + apply_route_data(session, state, RouteData{}); + session->route_loader->start(session->route_name, session->data_dir, session->dbc_override); + state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name; +} + +void poll_async_route_load(AppSession *session, UiState *state) { + if (!session->route_loader) { + return; + } + RouteData loaded_route; + std::string error_text; + if (!session->route_loader->consume(&loaded_route, &error_text)) { + return; + } + if (!error_text.empty()) { + state->error_text = error_text; + state->open_error_popup = true; + state->status_text = "Failed to load route"; + return; + } + apply_route_data(session, state, std::move(loaded_route)); + state->status_text = session->route_name.empty() ? "Ready" : "Loaded route " + session->route_name; +} + +bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir) { + try { + stop_stream_session(session, state, false); + session->data_mode = SessionDataMode::Route; + session->route_name = route_name; + session->route_id = parse_route_identifier(route_name); + session->data_dir = data_dir; + if (session->async_route_loading) { + if (!session->route_loader) { + session->route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); + } + start_async_route_load(session, state); + } else { + rebuild_session_route_data(session, state); + state->status_text = "Loaded route " + route_name; + } + sync_route_buffers(state, *session); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to load route"; + return false; + } +} + +void draw_popups(AppSession *session, UiState *state) { + open_queued_popup(state->open_open_route, "Open Route"); + if (state->open_stream) { + sync_stream_buffers(state, *session); + } + open_queued_popup(state->open_stream, "Live Stream"); + if (state->open_load_layout || state->open_save_layout) { + sync_layout_buffers(state, *session); + } + open_queued_popup(state->open_load_layout, "Load Layout"); + open_queued_popup(state->open_save_layout, "Save Layout"); + open_queued_popup(state->open_preferences, "Preferences"); + open_queued_popup(state->dbc_editor.open, "DBC Editor"); + open_queued_popup(state->open_find_signal, "Find Signal"); + open_queued_popup(state->axis_limits.open, "Edit Axis Limits"); + + draw_open_route_popup(session, state); + draw_stream_popup(session, state); + draw_load_layout_popup(session, state); + draw_save_layout_popup(session, state); + draw_preferences_popup(session, state); + draw_dbc_editor_popup(session, state); + draw_find_signal_popup(session, state); + draw_axis_limits_popup(session, state); + draw_error_popup(state); +} diff --git a/tools/jotpluggler/layout_io.cc b/tools/jotpluggler/layout_io.cc new file mode 100644 index 000000000..f984c0f0e --- /dev/null +++ b/tools/jotpluggler/layout_io.cc @@ -0,0 +1,128 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +std::string curve_color_hex(const std::array &color) { + std::ostringstream hex; + hex << "#" << std::hex << std::setfill('0') + << std::setw(2) << static_cast(color[0]) + << std::setw(2) << static_cast(color[1]) + << std::setw(2) << static_cast(color[2]); + return hex.str(); +} + +json11::Json curve_to_json(const Curve &curve) { + json11::Json::object obj = { + {"name", curve.name}, + {"color", curve_color_hex(curve.color)}, + }; + if (curve.derivative) { + obj["transform"] = "derivative"; + if (curve.derivative_dt > 0.0) { + obj["derivative_dt"] = curve.derivative_dt; + } + } else if (std::abs(curve.value_scale - 1.0) > 1.0e-9 || std::abs(curve.value_offset) > 1.0e-9) { + obj["transform"] = "scale"; + obj["scale"] = curve.value_scale; + obj["offset"] = curve.value_offset; + } + if (curve.custom_python.has_value()) { + json11::Json::array additional_sources; + for (const std::string &path : curve.custom_python->additional_sources) { + additional_sources.push_back(path); + } + obj["custom_python"] = json11::Json::object{ + {"linked_source", curve.custom_python->linked_source}, + {"additional_sources", additional_sources}, + {"globals_code", curve.custom_python->globals_code}, + {"function_code", curve.custom_python->function_code}, + }; + } + return obj; +} + +json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTab &tab) { + if (node.is_pane) { + if (node.pane_index < 0 || node.pane_index >= static_cast(tab.panes.size())) { + return nullptr; + } + const Pane &pane = tab.panes[static_cast(node.pane_index)]; + json11::Json::object obj = { + {"title", pane.title.empty() ? std::string("...") : pane.title}, + }; + if (pane.kind == PaneKind::Map) { + obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Camera) { + obj["kind"] = "camera"; + obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; + } + if (pane.range.valid) { + obj["range"] = json11::Json::object{ + {"left", pane.range.left}, {"right", pane.range.right}, + {"top", pane.range.top}, {"bottom", pane.range.bottom}, + }; + } + if (pane.range.has_y_limit_min || pane.range.has_y_limit_max) { + json11::Json::object limits; + if (pane.range.has_y_limit_min) { + limits["min"] = pane.range.y_limit_min; + } + if (pane.range.has_y_limit_max) { + limits["max"] = pane.range.y_limit_max; + } + obj["y_limits"] = limits; + } + json11::Json::array curves; + for (const Curve &curve : pane.curves) { + if (!curve.runtime_only) { + curves.push_back(curve_to_json(curve)); + } + } + obj["curves"] = curves; + return obj; + } + + if (node.children.empty()) return nullptr; + json11::Json::array sizes; + for (size_t i = 0; i < node.children.size(); ++i) { + sizes.push_back(i < node.sizes.size() ? static_cast(node.sizes[i]) + : 1.0 / static_cast(node.children.size())); + } + json11::Json::array children; + for (const WorkspaceNode &child : node.children) { + children.push_back(workspace_node_to_json(child, tab)); + } + return json11::Json::object{ + {"split", node.orientation == SplitOrientation::Horizontal ? "horizontal" : "vertical"}, + {"sizes", sizes}, + {"children", children}, + }; +} + +} // namespace + +void save_layout_json(const SketchLayout &layout, const fs::path &path) { + ensure_parent_dir(path); + json11::Json::array tabs; + for (const WorkspaceTab &tab : layout.tabs) { + tabs.push_back(json11::Json::object{ + {"name", tab.tab_name}, + {"root", workspace_node_to_json(tab.root, tab)}, + }); + } + const json11::Json root = json11::Json::object{ + {"current_tab_index", std::clamp(layout.current_tab_index, 0, std::max(0, static_cast(layout.tabs.size()) - 1))}, + {"tabs", tabs}, + }; + write_file_or_throw(path, root.dump() + "\n"); +} diff --git a/tools/jotpluggler/layouts/.gitignore b/tools/jotpluggler/layouts/.gitignore new file mode 100644 index 000000000..a965bb777 --- /dev/null +++ b/tools/jotpluggler/layouts/.gitignore @@ -0,0 +1 @@ +.jotpluggler_autosave/ diff --git a/tools/jotpluggler/layouts/CAN-bus-debug.json b/tools/jotpluggler/layouts/CAN-bus-debug.json new file mode 100644 index 000000000..496993a1f --- /dev/null +++ b/tools/jotpluggler/layouts/CAN-bus-debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.33362,0.33276,0.33362],"children":[{"title":"CAN RX","range":{"left":0.0,"right":60.526742,"top":1101.875,"bottom":-26.875},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN TX","range":{"left":0.0,"right":60.526742,"top":455.1,"bottom":-11.1},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#17becf","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#bcbd22","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN errors","range":{"left":0.0,"right":60.526742,"top":2515.35,"bottom":-61.35},"curves":[{"name":"/pandaStates/0/canState0/totalErrorCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalErrorCnt","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalErrorCnt","color":"#1ac938","transform":"derivative","derivative_dt":1.0}]}]}}]} diff --git a/tools/jotpluggler/layouts/camera-timings.json b/tools/jotpluggler/layouts/camera-timings.json new file mode 100644 index 000000000..64decf15d --- /dev/null +++ b/tools/jotpluggler/layouts/camera-timings.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/driverCameraState/sensor","color":"#bcbd22"},{"name":"/roadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]} diff --git a/tools/jotpluggler/layouts/cameras-and-map.json b/tools/jotpluggler/layouts/cameras-and-map.json new file mode 100644 index 000000000..68c590f7b --- /dev/null +++ b/tools/jotpluggler/layouts/cameras-and-map.json @@ -0,0 +1 @@ +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]} diff --git a/tools/jotpluggler/layouts/can-states.json b/tools/jotpluggler/layouts/can-states.json new file mode 100644 index 000000000..6f04940a3 --- /dev/null +++ b/tools/jotpluggler/layouts/can-states.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.500381,0.499619],"children":[{"split":"horizontal","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":771630.925,"bottom":-17755.925},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#1f77b4"},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#d62728"},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":760365.5,"bottom":-18545.5},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#9467bd"}]}]},{"split":"horizontal","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":55.35,"bottom":-1.35},"curves":[{"name":"/pandaStates/0/canState0/totalRxLostCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalRxLostCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalRxLostCnt","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":2.05,"bottom":-0.05},"curves":[{"name":"/pandaStates/0/canState0/totalTxLostCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/totalTxLostCnt","color":"#bcbd22"},{"name":"/pandaStates/0/canState2/totalTxLostCnt","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":0.1,"bottom":-0.1},"curves":[{"name":"/pandaStates/0/canState0/busOffCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/busOffCnt","color":"#1ac938"},{"name":"/pandaStates/0/canState2/busOffCnt","color":"#bcbd22"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/controls_mismatch_debug.json b/tools/jotpluggler/layouts/controls_mismatch_debug.json new file mode 100644 index 000000000..16912cd68 --- /dev/null +++ b/tools/jotpluggler/layouts/controls_mismatch_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.2,0.2,0.2,0.2,0.2],"children":[{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#1f77b4"},{"name":"/pandaStates/0/controlsAllowed","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":27.087398,"bottom":-0.905168},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/pandaStates/0/safetyRxInvalid","color":"#1f77b4"},{"name":"/pandaStates/0/safetyRxChecksInvalid","color":"#e801ce"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":158.85,"bottom":-2.85},"curves":[{"name":"/pandaStates/0/safetyTxBlocked","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carState/gasPressed","color":"#1ac938"},{"name":"/carState/brakePressed","color":"#ff7f0e"}]}]}}]} diff --git a/tools/jotpluggler/layouts/gps.json b/tools/jotpluggler/layouts/gps.json new file mode 100644 index 000000000..fdabbfd38 --- /dev/null +++ b/tools/jotpluggler/layouts/gps.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.24977,0.250689,0.24977,0.24977],"children":[{"title":"...","range":{"left":0.0,"right":1678.753571,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/hasFix","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":17.425,"bottom":-0.425},"curves":[{"name":"/gpsLocationExternal/satelliteCount","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":3.0,"bottom":0.0},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}],"y_limits":{"min":0.0,"max":3.0}},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":766.374004,"bottom":-17.262},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}]}]}}]} diff --git a/tools/jotpluggler/layouts/gps_vs_llk.json b/tools/jotpluggler/layouts/gps_vs_llk.json new file mode 100644 index 000000000..878e0a57a --- /dev/null +++ b/tools/jotpluggler/layouts/gps_vs_llk.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333805,0.33239,0.333805],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":32.070386,"bottom":0.368228},"curves":[{"name":"haversine distance [m]","color":"#1f77b4","custom_python":{"linked_source":"/gpsLocationExternal/latitude","additional_sources":["/gpsLocationExternal/longitude","/liveLocationKalmanDEPRECATED/positionGeodetic/value/0","/liveLocationKalmanDEPRECATED/positionGeodetic/value/1"],"globals_code":"R = 6378.137 # Radius of earth in KM","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global R\n # Compute the Haversine distance between\n # two points defined by latitude and longitude.\n # Return the distance in meters\n lat1, lon1 = value, v1\n lat2, lon2 = v2, v3\n dLat = (lat2 - lat1) * np.pi / 180\n dLon = (lon2 - lon1) * np.pi / 180\n a = np.sin(dLat/2) * np.sin(dLat/2) +\n np.cos(lat1 * np.pi / 180) * np.cos(lat2 * np.pi / 180) *\n np.sin(dLon/2) * np.sin(dLon/2)\n c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))\n d = R * c\n distance = d * 1000 # meters\n return distance\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":12.637299,"bottom":-0.259115},"curves":[{"name":"/carState/vEgo","color":"#17becf"},{"name":"/gpsLocationExternal/speed","color":"#bcbd22"}]},{"split":"horizontal","sizes":[0.500516,0.499484],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":0.1,"bottom":-0.1},"curves":[{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/0","color":"#d62728"},{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/1","color":"#1ac938"}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":7.160833,"bottom":-0.449385},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#ff7f0e"},{"name":"/gpsLocationExternal/verticalAccuracy","color":"#f14cc1"},{"name":"/gpsLocationExternal/speedAccuracy","color":"#9467bd"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/locationd_debug.json b/tools/jotpluggler/layouts/locationd_debug.json new file mode 100644 index 000000000..0541427bc --- /dev/null +++ b/tools/jotpluggler/layouts/locationd_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/livePose/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/liveCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]} diff --git a/tools/jotpluggler/layouts/longitudinal.json b/tools/jotpluggler/layouts/longitudinal.json new file mode 100644 index 000000000..27f43eb35 --- /dev/null +++ b/tools/jotpluggler/layouts/longitudinal.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.250401,0.249599,0.250401,0.249599],"children":[{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.391623,"bottom":-2.563614},"curves":[{"name":"/carState/aEgo","color":"#f14cc1"},{"name":"/longitudinalPlan/accels/0","color":"#9467bd"},{"name":"/carControl/actuators/accel","color":"#17becf"},{"name":"/carOutput/actuatorsOutput/accel","color":"#d62728"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.18496,"bottom":-1.811222},"curves":[{"name":"/controlsState/upAccelCmd","color":"#1f77b4"},{"name":"/controlsState/uiAccelCmd","color":"#d62728"},{"name":"/controlsState/ufAccelCmd","color":"#1ac938"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":15.862889,"bottom":-0.568809},"curves":[{"name":"/carState/vEgo","color":"#1ac938"},{"name":"/longitudinalPlan/speeds/0","color":"#ff7f0e"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/longActive","color":"#1f77b4"},{"name":"/carState/gasPressed","color":"#d62728"}]}]}}]} diff --git a/tools/jotpluggler/layouts/max-torque-debug.json b/tools/jotpluggler/layouts/max-torque-debug.json new file mode 100644 index 000000000..3a87fb321 --- /dev/null +++ b/tools/jotpluggler/layouts/max-torque-debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]} diff --git a/tools/jotpluggler/layouts/new-layout.json b/tools/jotpluggler/layouts/new-layout.json new file mode 100644 index 000000000..bffb62d7c --- /dev/null +++ b/tools/jotpluggler/layouts/new-layout.json @@ -0,0 +1 @@ +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"curves": [], "title": "..."}}]} diff --git a/tools/jotpluggler/layouts/system_lag_debug.json b/tools/jotpluggler/layouts/system_lag_debug.json new file mode 100644 index 000000000..281de440f --- /dev/null +++ b/tools/jotpluggler/layouts/system_lag_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249729,0.250814,0.249729,0.249729],"children":[{"title":"...","range":{"left":0.0,"right":59.992103,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"},{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":64.005001,"bottom":51.195},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#d62728"},{"name":"/deviceState/cpuTempC/1","color":"#1ac938"},{"name":"/deviceState/cpuTempC/2","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/3","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/4","color":"#9467bd"},{"name":"/deviceState/cpuTempC/5","color":"#17becf"},{"name":"/deviceState/cpuTempC/6","color":"#bcbd22"},{"name":"/deviceState/cpuTempC/7","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":37.371108,"bottom":-0.91149},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":-3.593455,"bottom":-12.190956},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]}]}}]} diff --git a/tools/jotpluggler/layouts/thermal_debug.json b/tools/jotpluggler/layouts/thermal_debug.json new file mode 100644 index 000000000..3a7ce454c --- /dev/null +++ b/tools/jotpluggler/layouts/thermal_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166785,0.166785,0.166075,0.166785,0.166785,0.166785],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":87.987497,"bottom":75.912497},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#1f77b4"},{"name":"/deviceState/cpuTempC/1","color":"#d62728"},{"name":"/deviceState/cpuTempC/2","color":"#1ac938"},{"name":"/deviceState/cpuTempC/3","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/4","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/5","color":"#9467bd"},{"name":"/deviceState/cpuTempC/6","color":"#17becf"},{"name":"/deviceState/cpuTempC/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":85.861052,"bottom":66.49695},"curves":[{"name":"/deviceState/pmicTempC/0","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"},{"name":"/deviceState/memoryTempC","color":"#f14cc1"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":86.207876,"bottom":70.665918},"curves":[{"name":"/deviceState/maxTempC","color":"#1f77b4"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceState/thermalStatus","color":"#1f77b4"}]},{"split":"horizontal","sizes":[0.333124,0.333752,0.333124],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":12.057358,"bottom":4.843517},"curves":[{"name":"/deviceState/powerDrawW","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.0,"bottom":0.0},"curves":[{"name":"/deviceState/fanSpeedPercentDesired","color":"#9467bd"},{"name":"/pandaStates/0/fanPower","color":"#1f77b4"}],"y_limits":{"min":0.0,"max":100.0}},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":5018.4,"bottom":255.6},"curves":[{"name":"/peripheralState/fanSpeedRpm","color":"#1f77b4"}]}]},{"split":"horizontal","sizes":[0.502513,0.497487],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.025,"bottom":14.975},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/torque-controller.json b/tools/jotpluggler/layouts/torque-controller.json new file mode 100644 index 000000000..7e269e59e --- /dev/null +++ b/tools/jotpluggler/layouts/torque-controller.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"Lateral Plan Conformance","root":{"split":"vertical","sizes":[0.250949,0.249051,0.250949,0.249051],"children":[{"title":"desired vs actual lateral acceleration (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":1.858161,"bottom":-1.823407},"curves":[{"name":"/controlsState/lateralControlState/torqueState/actualLateralAccel","color":"#1f77b4"},{"name":"/controlsState/lateralControlState/torqueState/desiredLateralAccel","color":"#d62728"}]},{"title":"desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":2.749816,"bottom":-3.723091},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891674,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"vehicle speed","range":{"left":0.000194,"right":1138.891674,"top":105.981304,"bottom":-2.709314},"curves":[{"name":"carState.vEgo mph","color":"#d62728","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 2.23694\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"carState.vEgo kmh","color":"#1ac938","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 3.6\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/carState/vEgo","color":"#ff7f0e"}]}]}},{"name":"Vehicle Dynamics","root":{"split":"vertical","sizes":[0.334282,0.331437,0.334282],"children":[{"title":"configured-initial vs online-learned steerRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":19.665784,"bottom":19.359553},"curves":[{"name":"/carParams/steerRatio","color":"#1f77b4"},{"name":"/liveParameters/steerRatio","color":"#1ac938"}]},{"title":"configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":1.11221,"bottom":0.995631},"curves":[{"name":"/carParams/tireStiffnessFactor","color":"#d62728"},{"name":"/liveParameters/stiffnessFactor","color":"#ff7f0e"}]},{"title":"live steering angle offsets for straight-ahead driving, large values here may indicate alignment problems","range":{"left":0.0,"right":1138.816328,"top":-1.081041,"bottom":-4.494133},"curves":[{"name":"/liveParameters/angleOffsetAverageDeg","color":"#f14cc1"},{"name":"/liveParameters/angleOffsetDeg","color":"#9467bd"}]}]}},{"name":"Actuator Performance","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output","range":{"left":0.0,"right":1138.920072,"top":1.21611,"bottom":0.539474},"curves":[{"name":"/liveTorqueParameters/latAccelFactorFiltered","color":"#1f77b4"},{"name":"/liveTorqueParameters/latAccelFactorRaw","color":"#d62728"},{"name":"/carParams/lateralTuning/torque/latAccelFactor","color":"#1c9222"}]},{"title":"learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel","range":{"left":0.0,"right":1138.920072,"top":-0.304367,"bottom":-0.418688},"curves":[{"name":"/liveTorqueParameters/latAccelOffsetFiltered","color":"#1ac938"},{"name":"/liveTorqueParameters/latAccelOffsetRaw","color":"#ff7f0e"}]},{"title":"offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel","range":{"left":0.0,"right":1138.920072,"top":0.226389,"bottom":0.15805},"curves":[{"name":"/liveTorqueParameters/frictionCoefficientFiltered","color":"#f14cc1"},{"name":"/liveTorqueParameters/frictionCoefficientRaw","color":"#9467bd"},{"name":"/carParams/lateralTuning/torque/friction","color":"#1c9222"}]}]}},{"name":"Actuator Delay","root":{"split":"vertical","sizes":[0.30441,0.358464,0.337127],"children":[{"title":"actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid","range":{"left":0.0,"right":1138.749979,"top":1.025,"bottom":-0.025},"curves":[{"name":"/liveDelay/status","color":"#ff7f0e"}]},{"title":"offline default vs online estimated steering actuator lag","range":{"left":0.0,"right":1138.749979,"top":0.419648,"bottom":0.318362},"curves":[{"name":"/liveDelay/lateralDelay","color":"#1f77b4"},{"name":"/liveDelay/lateralDelayEstimate","color":"#d62728"},{"name":"opendbc default steering lag","color":"#1ac938","custom_python":{"linked_source":"/carParams/steerActuatorDelay","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value + 0.2\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"online estimated steering actuator lag, standard deviation","range":{"left":0.0,"right":1138.749979,"top":0.06732,"bottom":-0.001642},"curves":[{"name":"/liveDelay/lateralDelayEstimateStd","color":"#f14cc1"}]}]}},{"name":"Controls Performance","root":{"split":"vertical","sizes":[0.265655,0.251898,0.245731,0.236717],"children":[{"title":"rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)","range":{"left":0.000194,"right":1138.891921,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carControl/actuators/torque","color":"#0c00f2"},{"name":"/carOutput/actuatorsOutput/torque","color":"#2cd63a"}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891921,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)","range":{"left":0.000194,"right":1138.891921,"top":2.099784,"bottom":-4.027542},"curves":[{"name":"/controlsState/lateralControlState/torqueState/f","color":"#0ab027"},{"name":"/controlsState/lateralControlState/torqueState/p","color":"#d62728"},{"name":"/controlsState/lateralControlState/torqueState/i","color":"#ffaf00"},{"name":"Zero","color":"#756a6a","custom_python":{"linked_source":"/carState/canValid","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return (0)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"road roll angle, from openpilot localizer","range":{"left":0.000194,"right":1138.891921,"top":0.109446,"bottom":-0.045525},"curves":[{"name":"/liveParameters/roll","color":"#f14cc1"}]}]}}]} diff --git a/tools/jotpluggler/layouts/tuning.json b/tools/jotpluggler/layouts/tuning.json new file mode 100644 index 000000000..0a8e81743 --- /dev/null +++ b/tools/jotpluggler/layouts/tuning.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"Lateral","root":{"split":"vertical","sizes":[0.200458,0.199313,0.200458,0.199313,0.200458],"children":[{"title":"Velocity [m/s]","range":{"left":1.253354,"right":631.055584,"top":29.954036,"bottom":-0.841715},"curves":[{"name":"/carState/vEgo","color":"#0072b2"}]},{"title":"Curvature [1/m] True [blue] Vehicle Model [purple] Plan [green]","range":{"left":0.0,"right":631.055209,"top":0.006648,"bottom":-0.00315},"curves":[{"name":"engaged curvature plan","color":"#009e73","custom_python":{"linked_source":"/modelV2/action/desiredCurvature","additional_sources":["/carState/steeringPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n global engage_delay, last_bad_time\n curvature = value\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"engaged curvature vehicle model","color":"#785ef0","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/steeringPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n global engage_delay, last_bad_time\n curvature = value\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"engaged curvature yaw","color":"#0072b2","custom_python":{"linked_source":"/carControl/angularVelocity/2","additional_sources":["/carState/steeringPressed","/carControl/enabled","/carState/vEgo"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n curvature = value / v3\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return curvature\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"Roll [rad]","range":{"left":0.0,"right":631.038276,"top":0.166067,"bottom":-1.598381},"curves":[{"name":"/carControl/orientationNED/0","color":"#ffb000"}]},{"title":"Engaged [green] Steering Pressed [blue]","range":{"left":1.252984,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"/selfdriveState/enabled","color":"#009e73"},{"name":"/carState/steeringPressed","color":"#0072b2"}]},{"title":"Steering Limited: Rate [orange] Saturated [magenta]","range":{"left":1.253354,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"steering rate limited","color":"#ffb000","custom_python":{"linked_source":"/carControl/actuators/torque","additional_sources":["/carOutput/actuatorsOutput/torque","/carControl/actuators/steeringAngleDeg","/carOutput/actuatorsOutput/steeringAngleDeg"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n return (np.abs(value - v1) > 0.001 or np.abs(v2 - v3) > 0.05) and 1 or 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/controlsState/lateralControlState/pidState/saturated","color":"#dc267f"}]}]}},{"name":"Longitudinal","root":{"split":"vertical","sizes":[0.1875,0.1875,0.1875,0.1875,0.25],"children":[{"title":"Velocity [m/s] True [blue] Plan [green] Cruise [magenta]","range":{"left":0.0,"right":631.055584,"top":42.713492,"bottom":-1.041792},"curves":[{"name":"/carState/cruiseState/speed","color":"#dc267f"},{"name":"/longitudinalPlan/speeds/0","color":"#009e73"},{"name":"/carState/vEgo","color":"#0072b2"}]},{"title":"Acceleration [m/s^2] True [blue] Actuator [purple] Plan [green]","range":{"left":1.253354,"right":631.055759,"top":0.808303,"bottom":-1.213305},"curves":[{"name":"engaged_accel_plan","color":"#009e73","custom_python":{"linked_source":"/longitudinalPlan/accels/0","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"engaged_accel_actuator","color":"#785ef0","custom_python":{"linked_source":"/carControl/actuators/accel","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"engaged_accel_actual","color":"#0072b2","custom_python":{"linked_source":"/carState/aEgo","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"Pitch [rad]","range":{"left":0.0,"right":631.038276,"top":0.158854,"bottom":-0.594843},"curves":[{"name":"/carControl/orientationNED/1","color":"#ffb000"}]},{"title":"Engaged [green] Gas [orange] Brake [magenta]","range":{"left":1.253354,"right":631.055759,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#009e73"},{"name":"/carState/gasPressed","color":"#ffb000"},{"name":"/carState/brakePressed","color":"#dc267f"}]},{"title":"State [blue: off,pid,stop,start] Source [green: cruise,lead0,lead1,lead2,e2e]","range":{"left":1.25362,"right":631.055759,"top":5.125,"bottom":-0.125},"curves":[{"name":"/carControl/actuators/longControlState","color":"#0072b2"},{"name":"/longitudinalPlan/longitudinalPlanSource","color":"#009e73"}]}]}},{"name":"Lateral Debug","root":{"split":"vertical","sizes":[0.25,0.25,0.25,0.25],"children":[{"title":"Controller F [magenta] P [purple] I [blue]","range":{"left":0.0,"right":1.0,"top":1.0,"bottom":0.0},"curves":[{"name":"/controlsState/lateralControlState/pidState/f","color":"#f14cc1"},{"name":"/controlsState/lateralControlState/pidState/p","color":"#9467bd"},{"name":"/controlsState/lateralControlState/pidState/i","color":"#17becf"}]},{"title":"Driver Torque [blue] EPS Torque [green]","range":{"left":1.253354,"right":631.055584,"top":2690.99903,"bottom":-3450.198981},"curves":[{"name":"/carState/steeringTorqueEps","color":"#009e73"},{"name":"/carState/steeringTorque","color":"#0072b2"}]},{"title":"Engaged [green] Steering Pressed [blue]","range":{"left":1.253354,"right":631.055759,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#009e73"},{"name":"/carState/steeringPressed","color":"#0072b2"}]},{"title":"Steering Limited: Rate [orange] Saturated [magenta]","range":{"left":1.253354,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"steering rate limited","color":"#ffb000","custom_python":{"linked_source":"/carControl/actuators/torque","additional_sources":["/carOutput/actuatorsOutput/torque","/carControl/actuators/steeringAngleDeg","/carOutput/actuatorsOutput/steeringAngleDeg"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n return (np.abs(value - v1) > 0.001 or np.abs(v2 - v3) > 0.05) and 1 or 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/controlsState/lateralControlState/pidState/saturated","color":"#dc267f"}]}]}}]} diff --git a/tools/jotpluggler/layouts/ublox-debug.json b/tools/jotpluggler/layouts/ublox-debug.json new file mode 100644 index 000000000..4509a192d --- /dev/null +++ b/tools/jotpluggler/layouts/ublox-debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":134.825489,"top":4402341.574525,"bottom":-107369.555525},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/flags","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":6.15,"bottom":-0.15},"curves":[{"name":"/ubloxGnss/measurementReport/numMeas","color":"#1ac938"}]}]}}]} diff --git a/tools/jotpluggler/logs.cc b/tools/jotpluggler/logs.cc new file mode 100644 index 000000000..4da1cbf50 --- /dev/null +++ b/tools/jotpluggler/logs.cc @@ -0,0 +1,419 @@ +#include "tools/jotpluggler/app.h" + +#include +#include + +namespace { + +struct LevelOption { + const char *label; + int value; +}; + +constexpr std::array LEVEL_OPTIONS = {{ + {"DEBUG", 10}, + {"INFO", 20}, + {"WARNING", 30}, + {"ERROR", 40}, + {"CRITICAL", 50}, +}}; +constexpr uint32_t ALL_LEVEL_MASK = (1u << LEVEL_OPTIONS.size()) - 1u; + +bool log_matches_search(const LogEntry &entry, std::string_view query) { + if (query.empty()) return true; + const std::string needle = lowercase_copy(query); + const auto contains = [&](std::string_view haystack) { + return lowercase_copy(haystack).find(needle) != std::string::npos; + }; + return contains(entry.message) || contains(entry.source) || contains(entry.func); +} + +std::vector collect_log_sources(const std::vector &logs) { + std::vector sources; + for (const LogEntry &entry : logs) { + if (entry.source.empty()) continue; + if (std::find(sources.begin(), sources.end(), entry.source) == sources.end()) { + sources.push_back(entry.source); + } + } + std::sort(sources.begin(), sources.end()); + return sources; +} + +std::vector filter_log_indices(const RouteData &route_data, const LogsUiState &logs_state) { + std::vector indices; + indices.reserve(route_data.logs.size()); + for (size_t i = 0; i < route_data.logs.size(); ++i) { + const LogEntry &entry = route_data.logs[i]; + int level_index = 0; + if (entry.level >= 50) { + level_index = 4; + } else if (entry.level >= 40) { + level_index = 3; + } else if (entry.level >= 30) { + level_index = 2; + } else if (entry.level >= 20) { + level_index = 1; + } + if ((logs_state.enabled_levels_mask & (1u << level_index)) == 0) { + continue; + } + if (!logs_state.all_sources) { + const auto it = std::find(logs_state.selected_sources.begin(), + logs_state.selected_sources.end(), + entry.source); + if (it == logs_state.selected_sources.end()) continue; + } + if (!log_matches_search(entry, logs_state.search)) continue; + indices.push_back(static_cast(i)); + } + return indices; +} + +int find_active_log_position(const RouteData &route_data, + const std::vector &filtered_indices, + double tracker_time) { + if (filtered_indices.empty()) return -1; + auto it = std::lower_bound(filtered_indices.begin(), filtered_indices.end(), tracker_time, + [&](int log_index, double tm) { + return route_data.logs[static_cast(log_index)].mono_time < tm; + }); + if (it == filtered_indices.begin()) return static_cast(std::distance(filtered_indices.begin(), it)); + if (it == filtered_indices.end()) return static_cast(filtered_indices.size()) - 1; + if (route_data.logs[static_cast(*it)].mono_time > tracker_time) { + --it; + } + return static_cast(std::distance(filtered_indices.begin(), it)); +} + +std::string format_route_time(double seconds) { + if (seconds < 0.0) { + seconds = 0.0; + } + const int minutes = static_cast(seconds / 60.0); + const double remaining = seconds - static_cast(minutes) * 60.0; + return util::string_format("%d:%06.3f", minutes, remaining); +} + +std::string format_boot_time(double seconds) { + return util::string_format("%.3f", seconds); +} + +std::string format_wall_time(double seconds) { + if (seconds <= 0.0) return "--"; + const time_t wall_seconds = static_cast(seconds); + std::tm wall_tm = {}; + localtime_r(&wall_seconds, &wall_tm); + const int millis = static_cast(std::llround((seconds - std::floor(seconds)) * 1000.0)); + return util::string_format("%02d:%02d:%02d.%03d", + wall_tm.tm_hour, wall_tm.tm_min, wall_tm.tm_sec, millis); +} + +std::string format_log_time(const LogEntry &entry, LogTimeMode mode) { + switch (mode) { + case LogTimeMode::Route: + return format_route_time(entry.mono_time); + case LogTimeMode::Boot: + return format_boot_time(entry.boot_time); + case LogTimeMode::WallClock: + return format_wall_time(entry.wall_time); + } + return format_route_time(entry.mono_time); +} + +const char *time_mode_label(LogTimeMode mode) { + switch (mode) { + case LogTimeMode::Route: return "Route"; + case LogTimeMode::Boot: return "Boot"; + case LogTimeMode::WallClock: return "Wall clock"; + } + return "Route"; +} + +std::string level_filter_label(uint32_t mask) { + if (mask == ALL_LEVEL_MASK) return "All levels"; + if (mask == 0b11110) return "INFO+"; + if (mask == 0b11100) return "WARNING+"; + if (mask == 0b11000) return "ERROR+"; + if (mask == 0b10000) return "CRITICAL"; + + int enabled_count = 0; + const char *last_label = "None"; + for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) { + if ((mask & (1u << i)) == 0) { + continue; + } + ++enabled_count; + last_label = LEVEL_OPTIONS[i].label; + } + if (enabled_count == 0) return "None"; + if (enabled_count == 1) return last_label; + return "Custom"; +} + +std::string source_filter_label(const LogsUiState &logs_state, const std::vector &sources) { + if (logs_state.all_sources || logs_state.selected_sources.size() == sources.size()) { + return "All sources"; + } + if (logs_state.selected_sources.empty()) return "No sources"; + if (logs_state.selected_sources.size() == 1) return logs_state.selected_sources.front(); + return std::to_string(logs_state.selected_sources.size()) + " sources"; +} + +const char *level_label(const LogEntry &entry) { + if (entry.origin == LogOrigin::Alert) return "ALRT"; + if (entry.level >= 50) return "CRIT"; + if (entry.level >= 40) return "ERR"; + if (entry.level >= 30) return "WARN"; + if (entry.level >= 20) return "INFO"; + return "DBG"; +} + +ImVec4 level_text_color(const LogEntry &entry, bool active) { + if (active) return color_rgb(46, 54, 63); + if (entry.origin == LogOrigin::Alert) return color_rgb(50, 100, 200); + if (entry.level >= 50) return color_rgb(176, 26, 18); + if (entry.level >= 40) return color_rgb(200, 50, 40); + if (entry.level >= 30) return color_rgb(200, 130, 0); + if (entry.level >= 20) return color_rgb(80, 86, 94); + return color_rgb(126, 133, 141); +} + +ImU32 row_bg_color(const LogEntry &entry, bool active) { + if (active) return IM_COL32(80, 140, 210, 38); + return 0; +} + +void set_tracker_to_log(UiState *state, const LogEntry &entry) { + state->tracker_time = entry.mono_time; + state->has_tracker_time = true; + state->logs.last_auto_scroll_time = entry.mono_time; +} + +void draw_log_expansion_row(const LogEntry &entry) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(""); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(""); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(entry.func.empty() ? "" : entry.func.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(96, 104, 113)); + ImGui::TextWrapped("%s", entry.message.c_str()); + if (!entry.func.empty()) { + ImGui::TextWrapped("func: %s", entry.func.c_str()); + } + if (!entry.context.empty()) { + ImGui::TextWrapped("ctx: %s", entry.context.c_str()); + } + ImGui::PopStyleColor(); +} + +void draw_log_row(const LogEntry &entry, + int log_index, + bool active, + UiState *state) { + ImGui::PushID(log_index); + const ImU32 bg = row_bg_color(entry, active); + ImGui::TableNextRow(); + if (bg != 0) { + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, bg); + } + + const std::string time_text = std::string(active ? "\xE2\x96\xB6 " : " ") + format_log_time(entry, state->logs.time_mode); + const auto clickable_text = [&](const char *id, const std::string &text, ImVec4 color = color_rgb(74, 80, 88)) { + ImGui::PushID(id); + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0)); + const bool clicked = ImGui::Selectable(text.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick); + ImGui::PopStyleColor(4); + ImGui::PopID(); + return clicked; + }; + + bool clicked = false; + ImGui::TableSetColumnIndex(0); + app_push_mono_font(); + clicked = clickable_text("time", time_text); + app_pop_mono_font(); + + ImGui::TableSetColumnIndex(1); + clicked = clickable_text("level", level_label(entry), level_text_color(entry, active)) || clicked; + + ImGui::TableSetColumnIndex(2); + clicked = clickable_text("source", entry.source) || clicked; + + ImGui::TableSetColumnIndex(3); + clicked = clickable_text("message", entry.message) || clicked; + + if (clicked) { + set_tracker_to_log(state, entry); + state->logs.expanded_index = state->logs.expanded_index == log_index ? -1 : log_index; + } + ImGui::PopID(); +} + +} // namespace + +void draw_logs_tab(AppSession *session, UiState *state) { + LogsUiState &logs_state = state->logs; + const RouteData &route_data = session->route_data; + const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + const bool loading_logs = load.active && route_data.logs.empty(); + const std::vector sources = collect_log_sources(route_data.logs); + + if (!logs_state.all_sources) { + logs_state.selected_sources.erase( + std::remove_if(logs_state.selected_sources.begin(), + logs_state.selected_sources.end(), + [&](const std::string &source) { + return std::find(sources.begin(), sources.end(), source) == sources.end(); + }), + logs_state.selected_sources.end()); + } + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 3.0f)); + ImGui::SetNextItemWidth(110.0f); + const std::string levels_label = level_filter_label(logs_state.enabled_levels_mask); + if (ImGui::BeginCombo("##logs_level", levels_label.c_str())) { + bool all_levels = logs_state.enabled_levels_mask == ALL_LEVEL_MASK; + if (ImGui::Checkbox("All levels", &all_levels)) { + logs_state.enabled_levels_mask = all_levels ? ALL_LEVEL_MASK : 0u; + } + ImGui::Separator(); + for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) { + bool enabled = (logs_state.enabled_levels_mask & (1u << i)) != 0; + if (ImGui::Checkbox(LEVEL_OPTIONS[i].label, &enabled)) { + if (enabled) { + logs_state.enabled_levels_mask |= (1u << i); + } else { + logs_state.enabled_levels_mask &= ~(1u << i); + } + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(150.0f); + input_text_with_hint_string("##logs_search", "Search...", &logs_state.search); + ImGui::SameLine(); + + const std::string sources_label = source_filter_label(logs_state, sources); + ImGui::SetNextItemWidth(180.0f); + if (ImGui::BeginCombo("##logs_source", sources_label.c_str())) { + bool all_sources = logs_state.all_sources; + if (ImGui::Checkbox("All sources", &all_sources)) { + logs_state.all_sources = all_sources; + if (logs_state.all_sources) { + logs_state.selected_sources.clear(); + } else { + logs_state.selected_sources = sources; + } + } + ImGui::Separator(); + for (const std::string &source : sources) { + bool enabled = logs_state.all_sources + || std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source) != logs_state.selected_sources.end(); + if (ImGui::Checkbox(source.c_str(), &enabled)) { + if (logs_state.all_sources) { + logs_state.all_sources = false; + logs_state.selected_sources = sources; + } + auto it = std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source); + if (enabled) { + if (it == logs_state.selected_sources.end()) { + logs_state.selected_sources.push_back(source); + } + } else if (it != logs_state.selected_sources.end()) { + logs_state.selected_sources.erase(it); + } + if (logs_state.selected_sources.size() == sources.size()) { + logs_state.all_sources = true; + logs_state.selected_sources.clear(); + } + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(110.0f); + if (ImGui::BeginCombo("##logs_time_mode", time_mode_label(logs_state.time_mode))) { + for (LogTimeMode mode : {LogTimeMode::Route, LogTimeMode::Boot, LogTimeMode::WallClock}) { + const bool selected = logs_state.time_mode == mode; + if (ImGui::Selectable(time_mode_label(mode), selected)) { + logs_state.time_mode = mode; + } + } + ImGui::EndCombo(); + } + + const std::vector filtered_indices = filter_log_indices(route_data, logs_state); + const bool have_tracker = state->has_tracker_time && !filtered_indices.empty(); + const int active_pos = have_tracker ? find_active_log_position(route_data, filtered_indices, state->tracker_time) : -1; + + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - 110.0f)); + ImGui::Text("%zu / %zu", filtered_indices.size(), route_data.logs.size()); + ImGui::PopStyleVar(); + + if (route_data.logs.empty()) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133)); + ImGui::TextWrapped("%s", loading_logs ? "Loading logs..." : "No text logs available for this route."); + ImGui::PopStyleColor(); + return; + } + + if (ImGui::BeginChild("##logs_table_child", ImVec2(0.0f, 0.0f), false)) { + if (have_tracker && std::abs(logs_state.last_auto_scroll_time - state->tracker_time) > 1.0e-6) { + const float row_height = ImGui::GetTextLineHeightWithSpacing() + 6.0f; + const float visible_h = std::max(1.0f, ImGui::GetWindowHeight()); + const float target = std::max(0.0f, static_cast(active_pos) * row_height - visible_h * 0.45f); + ImGui::SetScrollY(target); + logs_state.last_auto_scroll_time = state->tracker_time; + } + + if (ImGui::BeginTable("##logs_table", + 4, + ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthFixed, 180.0f); + ImGui::TableSetupColumn("Message", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + const bool use_clipper = logs_state.expanded_index < 0; + if (use_clipper) { + ImGuiListClipper clipper; + clipper.Begin(static_cast(filtered_indices.size())); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + const int log_index = filtered_indices[static_cast(i)]; + const LogEntry &entry = route_data.logs[static_cast(log_index)]; + draw_log_row(entry, log_index, i == active_pos, state); + } + } + } else { + for (int i = 0; i < static_cast(filtered_indices.size()); ++i) { + const int log_index = filtered_indices[static_cast(i)]; + const LogEntry &entry = route_data.logs[static_cast(log_index)]; + draw_log_row(entry, log_index, i == active_pos, state); + if (logs_state.expanded_index == log_index) { + draw_log_expansion_row(entry); + } + } + } + + ImGui::EndTable(); + } + } + ImGui::EndChild(); +} diff --git a/tools/jotpluggler/main.cc b/tools/jotpluggler/main.cc new file mode 100644 index 000000000..22bc29664 --- /dev/null +++ b/tools/jotpluggler/main.cc @@ -0,0 +1,126 @@ +#include +#include + +#include "tools/jotpluggler/app.h" + +namespace { + +constexpr const char *DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496"; + +void print_usage(const char *argv0) { + std::cerr + << "Usage: " << argv0 << " [--layout ] [options] [route]\n" + << "\n" + << "Options:\n" + << " --demo\n" + << " --data-dir \n" + << " --stream\n" + << " --address \n" + << " --buffer-seconds \n" + << " --width \n" + << " --height \n" + << " --output \n" + << " --show\n" + << " --sync-load\n" + << "\n" + << "Examples:\n" + << " " << argv0 << "\n" + << " " << argv0 << " --demo\n" + << " " << argv0 << " --layout longitudinal --demo\n" + << " " << argv0 << " --layout longitudinal --demo --output /tmp/longitudinal.png\n" + << " " << argv0 << " --stream --show\n" + << " " << argv0 << " --stream --address 192.168.60.52 --buffer-seconds 45 --show\n"; +} + +bool parse_int(const char *value, int *out) { + char *end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == nullptr || *end != '\0') return false; + *out = static_cast(parsed); + return true; +} + +bool parse_double(const char *value, double *out) { + char *end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == nullptr || *end != '\0') return false; + *out = parsed; + return true; +} + +} // namespace + +int main(int argc, char *argv[]) { + Options options; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + const auto require_value = [&](const char *flag) -> const char * { + if (i + 1 >= argc) { + std::cerr << "Missing value for " << flag << "\n"; + print_usage(argv[0]); + std::exit(2); + } + return argv[++i]; + }; + + if (arg == "--layout") { + options.layout = require_value("--layout"); + } else if (arg == "--demo") { + options.route_name = DEMO_ROUTE; + } else if (arg == "--data-dir") { + options.data_dir = require_value("--data-dir"); + } else if (arg == "--stream") { + options.stream = true; + } else if (arg == "--address") { + options.stream_address = require_value("--address"); + } else if (arg == "--buffer-seconds") { + if (!parse_double(require_value("--buffer-seconds"), &options.stream_buffer_seconds)) { + std::cerr << "Invalid buffer seconds\n"; + return 2; + } + } else if (arg == "--output") { + options.output_path = require_value("--output"); + } else if (arg == "--width") { + if (!parse_int(require_value("--width"), &options.width)) { + std::cerr << "Invalid width\n"; + return 2; + } + } else if (arg == "--height") { + if (!parse_int(require_value("--height"), &options.height)) { + std::cerr << "Invalid height\n"; + return 2; + } + } else if (arg == "--show") { + options.show = true; + } else if (arg == "--sync-load") { + options.sync_load = true; + } else if (arg == "--help" || arg == "-h") { + print_usage(argv[0]); + return 0; + } else if (!arg.empty() && arg[0] != '-' && options.route_name.empty()) { + options.route_name = arg; + } else { + std::cerr << "Unknown argument: " << arg << "\n"; + print_usage(argv[0]); + return 2; + } + } + + if (options.output_path.empty() && !options.show) { + options.show = true; + } + if (options.width <= 0 || options.height <= 0) { + std::cerr << "Width and height must be positive\n"; + return 2; + } + if (options.stream && !options.route_name.empty()) { + std::cerr << "Route/file mode and --stream are mutually exclusive\n"; + return 2; + } + if (options.stream_buffer_seconds <= 0.0) { + std::cerr << "Buffer seconds must be positive\n"; + return 2; + } + + return run(options); +} diff --git a/tools/jotpluggler/map.cc b/tools/jotpluggler/map.cc new file mode 100644 index 000000000..8725908ea --- /dev/null +++ b/tools/jotpluggler/map.cc @@ -0,0 +1,1328 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/map.h" + +#include + +extern "C" { +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/util.h" +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +constexpr int MAP_MIN_ZOOM = 1; +constexpr int MAP_MAX_ZOOM = 18; +constexpr int MAP_SINGLE_POINT_MIN_ZOOM = 14; +constexpr float MAP_WHEEL_ZOOM_STEP = 0.25f; +constexpr double MAP_TRACE_PAD_FRAC = 0.45; +constexpr double MAP_TRACE_MIN_LAT_PAD = 0.01; +constexpr double MAP_BOUNDS_GRID = 0.005; +constexpr double MAP_CORRIDOR_LAT_PAD = 0.010; +constexpr double MAP_CORRIDOR_MIN_STEP_S = 1.5; +constexpr size_t MAP_CORRIDOR_MAX_BOXES = 36; +constexpr float MAP_INITIAL_FIT_FILL = 0.88f; +constexpr float MAP_MIN_ZOOM_FILL = 0.98f; +constexpr float MAP_EDGE_FADE_FRAC = 0.28f; +constexpr const char *MAP_QUERY_ENDPOINTS[] = { + "https://overpass-api.de/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +}; +struct GeoPoint { + double lat = 0.0; + double lon = 0.0; +}; + +struct ProjectedPoint { + float x = 0.0f; + float y = 0.0f; +}; + +struct ProjectedBounds { + float min_x = 0.0f; + float min_y = 0.0f; + float max_x = 0.0f; + float max_y = 0.0f; + + bool valid() const { + return max_x >= min_x && max_y >= min_y; + } +}; + +enum class RoadClass : uint8_t { + Motorway, + Primary, + Secondary, + Local, +}; + +struct RoadFeature { + RoadClass road_class = RoadClass::Local; + ProjectedBounds bounds; + std::vector points; +}; + +struct WaterLineFeature { + ProjectedBounds bounds; + std::vector points; +}; + +struct WaterPolygonFeature { + ProjectedBounds bounds; + std::vector ring; +}; + +} // namespace + +struct RouteBasemap { + std::string key; + GeoBounds bounds; + ProjectedBounds projected_bounds; + std::vector roads; + std::vector water_lines; + std::vector water_polygons; +}; + +struct MapRequestSpec { + std::string key; + GeoBounds bounds; + std::string query; +}; + +namespace { + +double lon_to_world_x(double lon, double zoom) { + return (lon + 180.0) / 360.0 * 256.0 * std::exp2(zoom); +} + +double lat_to_world_y(double lat, double zoom) { + const double lat_rad = lat * M_PI / 180.0; + return (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0 * std::exp2(zoom); +} + +double world_x_to_lon(double x, double zoom) { + return x / std::exp2(zoom) / 256.0 * 360.0 - 180.0; +} + +double world_y_to_lat(double y, double zoom) { + const double n = M_PI - (2.0 * M_PI * (y / std::exp2(zoom))) / 256.0; + return 180.0 / M_PI * std::atan(std::sinh(n)); +} + +double map_trace_center_lat(const GpsTrace &trace) { + return (trace.min_lat + trace.max_lat) * 0.5; +} + +double map_trace_center_lon(const GpsTrace &trace) { + return (trace.min_lon + trace.max_lon) * 0.5; +} + +double clamp_lat(double lat) { + return std::clamp(lat, -85.0, 85.0); +} + +double clamp_lon(double lon) { + return std::clamp(lon, -179.999, 179.999); +} + +float project_lon0(double lon) { + return static_cast((lon + 180.0) / 360.0 * 256.0); +} + +float project_lat0(double lat) { + const double lat_rad = lat * M_PI / 180.0; + return static_cast((1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0); +} + +double cos_lat_scale(double lat) { + return std::max(0.2, std::cos(lat * M_PI / 180.0)); +} + +double quantize_down(double value, double step) { + return std::floor(value / step) * step; +} + +double quantize_up(double value, double step) { + return std::ceil(value / step) * step; +} + +ProjectedBounds compute_projected_bounds(const std::vector &points) { + ProjectedBounds bounds; + if (points.empty()) { + return bounds; + } + bounds.min_x = bounds.max_x = points.front().x; + bounds.min_y = bounds.max_y = points.front().y; + for (const ProjectedPoint &point : points) { + bounds.min_x = std::min(bounds.min_x, point.x); + bounds.max_x = std::max(bounds.max_x, point.x); + bounds.min_y = std::min(bounds.min_y, point.y); + bounds.max_y = std::max(bounds.max_y, point.y); + } + return bounds; +} + +ProjectedBounds project_bounds0(const GeoBounds &bounds) { + if (!bounds.valid()) { + return {}; + } + return ProjectedBounds{ + .min_x = project_lon0(bounds.west), + .min_y = project_lat0(bounds.north), + .max_x = project_lon0(bounds.east), + .max_y = project_lat0(bounds.south), + }; +} + +bool feature_intersects_view(const ProjectedBounds &feature, const ProjectedBounds &view, float zoom_scale) { + const float min_x = feature.min_x * zoom_scale; + const float max_x = feature.max_x * zoom_scale; + const float min_y = feature.min_y * zoom_scale; + const float max_y = feature.max_y * zoom_scale; + return !(max_x < view.min_x || min_x > view.max_x + || max_y < view.min_y || min_y > view.max_y); +} + +GeoBounds requested_bounds_for_trace(const GpsTrace &trace) { + if (trace.points.empty()) { + return {}; + } + const double center_lat = map_trace_center_lat(trace); + const double lat_span = std::max(trace.max_lat - trace.min_lat, 0.002); + const double lon_span = std::max(trace.max_lon - trace.min_lon, 0.002 / cos_lat_scale(center_lat)); + const double lat_pad = std::max(lat_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD); + const double lon_pad = std::max(lon_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD / cos_lat_scale(center_lat)); + + GeoBounds bounds; + bounds.south = clamp_lat(quantize_down(trace.min_lat - lat_pad, MAP_BOUNDS_GRID)); + bounds.north = clamp_lat(quantize_up(trace.max_lat + lat_pad, MAP_BOUNDS_GRID)); + bounds.west = clamp_lon(quantize_down(trace.min_lon - lon_pad, MAP_BOUNDS_GRID)); + bounds.east = clamp_lon(quantize_up(trace.max_lon + lon_pad, MAP_BOUNDS_GRID)); + return bounds; +} + +GeoBounds merge_bounds(const GeoBounds &a, const GeoBounds &b) { + if (!a.valid()) return b; + if (!b.valid()) return a; + return GeoBounds{ + .south = std::min(a.south, b.south), + .west = std::min(a.west, b.west), + .north = std::max(a.north, b.north), + .east = std::max(a.east, b.east), + }; +} + +bool bounds_overlap_or_touch(const GeoBounds &a, const GeoBounds &b) { + return !(a.east < b.west || b.east < a.west || a.north < b.south || b.north < a.south); +} + +std::vector corridor_boxes_for_trace(const GpsTrace &trace) { + std::vector boxes; + if (trace.points.empty()) { + return boxes; + } + + const double center_lat = map_trace_center_lat(trace); + const double lon_pad = MAP_CORRIDOR_LAT_PAD / cos_lat_scale(center_lat); + const double total_time = trace.points.back().time - trace.points.front().time; + const double target_boxes = std::min(MAP_CORRIDOR_MAX_BOXES, std::max(8.0, total_time / MAP_CORRIDOR_MIN_STEP_S)); + const size_t stride = std::max(1, static_cast(std::ceil(trace.points.size() / target_boxes))); + + auto add_box = [&](double lat, double lon) { + GeoBounds box{ + .south = clamp_lat(quantize_down(lat - MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)), + .west = clamp_lon(quantize_down(lon - lon_pad, MAP_BOUNDS_GRID)), + .north = clamp_lat(quantize_up(lat + MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)), + .east = clamp_lon(quantize_up(lon + lon_pad, MAP_BOUNDS_GRID)), + }; + if (!box.valid()) { + return; + } + for (GeoBounds &existing : boxes) { + if (bounds_overlap_or_touch(existing, box)) { + existing = merge_bounds(existing, box); + return; + } + } + boxes.push_back(box); + }; + + add_box(trace.points.front().lat, trace.points.front().lon); + for (size_t i = stride; i < trace.points.size(); i += stride) { + add_box(trace.points[i].lat, trace.points[i].lon); + } + add_box(trace.points.back().lat, trace.points.back().lon); + + bool merged = true; + while (merged) { + merged = false; + for (size_t i = 0; i < boxes.size() && !merged; ++i) { + for (size_t j = i + 1; j < boxes.size(); ++j) { + if (bounds_overlap_or_touch(boxes[i], boxes[j])) { + boxes[i] = merge_bounds(boxes[i], boxes[j]); + boxes.erase(boxes.begin() + static_cast(j)); + merged = true; + break; + } + } + } + } + return boxes; +} + +ProjectedBounds view_bounds(double top_left_x, double top_left_y, float width, float height) { + return ProjectedBounds{ + .min_x = static_cast(top_left_x), + .min_y = static_cast(top_left_y), + .max_x = static_cast(top_left_x + width), + .max_y = static_cast(top_left_y + height), + }; +} + +int fit_map_zoom_for_bounds(const GeoBounds &bounds, float width, float height, float fill_fraction) { + if (!bounds.valid()) { + return MAP_MIN_ZOOM; + } + const double max_width = std::max(1.0f, width * fill_fraction); + const double max_height = std::max(1.0f, height * fill_fraction); + for (int z = MAP_MAX_ZOOM; z >= MAP_MIN_ZOOM; --z) { + const double pixel_width = std::abs(lon_to_world_x(bounds.east, z) - lon_to_world_x(bounds.west, z)); + const double pixel_height = std::abs(lat_to_world_y(bounds.south, z) - lat_to_world_y(bounds.north, z)); + if (pixel_width <= max_width && pixel_height <= max_height) { + return z; + } + } + return MAP_MIN_ZOOM; +} + +int fit_map_zoom_for_trace(const GpsTrace &trace, float width, float height) { + return fit_map_zoom_for_bounds(requested_bounds_for_trace(trace), width, height, MAP_INITIAL_FIT_FILL); +} + +int minimum_allowed_map_zoom(const GeoBounds &bounds, const GpsTrace &trace, ImVec2 size) { + if (trace.points.size() <= 1) { + return MAP_SINGLE_POINT_MIN_ZOOM; + } + const int fit_zoom = fit_map_zoom_for_bounds(bounds.valid() ? bounds : requested_bounds_for_trace(trace), + size.x, size.y, MAP_MIN_ZOOM_FILL); + return std::clamp(fit_zoom, MAP_MIN_ZOOM, MAP_MAX_ZOOM); +} + +std::optional interpolate_gps(const GpsTrace &trace, double time_value) { + if (trace.points.empty()) { + return std::nullopt; + } + if (time_value <= trace.points.front().time) { + return trace.points.front(); + } + if (time_value >= trace.points.back().time) { + return trace.points.back(); + } + auto upper = std::lower_bound(trace.points.begin(), trace.points.end(), time_value, + [](const GpsPoint &point, double target) { + return point.time < target; + }); + if (upper == trace.points.begin()) { + return trace.points.front(); + } + const GpsPoint &p1 = *upper; + const GpsPoint &p0 = *(upper - 1); + const double dt = p1.time - p0.time; + if (dt <= 1.0e-9) { + return p0; + } + const double alpha = (time_value - p0.time) / dt; + GpsPoint out; + out.time = time_value; + out.lat = p0.lat + (p1.lat - p0.lat) * alpha; + out.lon = p0.lon + (p1.lon - p0.lon) * alpha; + out.bearing = static_cast(p0.bearing + (p1.bearing - p0.bearing) * alpha); + out.type = alpha < 0.5 ? p0.type : p1.type; + return out; +} + +ImU32 map_timeline_color(TimelineEntry::Type type, float alpha = 1.0f) { + return timeline_entry_color(type, alpha, {140, 150, 165}); +} + +ImVec2 gps_to_screen(double lat, double lon, double zoom, double top_left_x, double top_left_y, const ImVec2 &rect_min) { + return ImVec2(rect_min.x + static_cast(lon_to_world_x(lon, zoom) - top_left_x), + rect_min.y + static_cast(lat_to_world_y(lat, zoom) - top_left_y)); +} + +bool point_in_rect_with_margin(const ImVec2 &point, const ImVec2 &rect_min, const ImVec2 &rect_max, + float margin_fraction) { + const float width = rect_max.x - rect_min.x; + const float height = rect_max.y - rect_min.y; + const float margin_x = width * margin_fraction; + const float margin_y = height * margin_fraction; + return point.x >= rect_min.x + margin_x && point.x <= rect_max.x - margin_x + && point.y >= rect_min.y + margin_y && point.y <= rect_max.y - margin_y; +} + +void draw_car_marker(ImDrawList *draw_list, ImVec2 center, float bearing_deg, ImU32 color, float size) { + const float rad = bearing_deg * static_cast(M_PI / 180.0); + const ImVec2 forward(std::sin(rad), -std::cos(rad)); + const ImVec2 perp(-forward.y, forward.x); + const ImVec2 tip(center.x + forward.x * size, center.y + forward.y * size); + const ImVec2 base(center.x - forward.x * size * 0.45f, center.y - forward.y * size * 0.45f); + const ImVec2 left(base.x + perp.x * size * 0.6f, base.y + perp.y * size * 0.6f); + const ImVec2 right(base.x - perp.x * size * 0.6f, base.y - perp.y * size * 0.6f); + draw_list->AddTriangleFilled(tip, left, right, color); + draw_list->AddTriangle(tip, left, right, IM_COL32(255, 255, 255, 210), 2.0f); +} + +bool is_convex_ring(const std::vector &points) { + if (points.size() < 4) { + return false; + } + float sign = 0.0f; + const size_t n = points.size(); + for (size_t i = 0; i < n; ++i) { + const ImVec2 &a = points[i]; + const ImVec2 &b = points[(i + 1) % n]; + const ImVec2 &c = points[(i + 2) % n]; + const float cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + if (std::abs(cross) < 1.0e-3f) { + continue; + } + if (sign == 0.0f) { + sign = cross; + } else if ((cross > 0.0f) != (sign > 0.0f)) { + return false; + } + } + return sign != 0.0f; +} + +uint64_t fnv1a64(std::string_view text) { + uint64_t value = 1469598103934665603ULL; + for (unsigned char c : text) { + value ^= static_cast(c); + value *= 1099511628211ULL; + } + return value; +} + +fs::path basemap_cache_root() { + const char *home = std::getenv("HOME"); + fs::path root = home != nullptr ? fs::path(home) / ".comma" : fs::temp_directory_path(); + root /= "jotpluggler_vector_map"; + fs::create_directories(root); + return root; +} + +std::string bounds_key(const GeoBounds &bounds) { + return util::string_format("v2_%.5f_%.5f_%.5f_%.5f", + bounds.south, bounds.west, bounds.north, bounds.east); +} + +fs::path basemap_cache_path(const std::string &key) { + const uint64_t hash = fnv1a64(key); + return basemap_cache_root() / util::string_format("%016llx.bin.zst", static_cast(hash)); +} + +uint64_t cache_directory_size_bytes() { + uint64_t total = 0; + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return 0; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + total += static_cast(entry.file_size()); + } + } + return total; +} + +size_t cache_directory_file_count() { + size_t count = 0; + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return 0; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + ++count; + } + } + return count; +} + +void clear_cache_directory() { + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + std::error_code ec; + fs::remove(entry.path(), ec); + } + } +} + +std::string percent_encode(std::string_view text) { + std::string out; + out.reserve(text.size() * 3); + for (unsigned char c : text) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else { + out += util::string_format("%%%02X", static_cast(c)); + } + } + return out; +} + +std::string bbox_string(const GeoBounds &bounds) { + return util::string_format("%.6f,%.6f,%.6f,%.6f", + bounds.south, bounds.west, bounds.north, bounds.east); +} + +MapRequestSpec build_request_for_trace(const GpsTrace &trace) { + const std::vector boxes = corridor_boxes_for_trace(trace); + GeoBounds union_bounds; + std::string query = "[out:json][timeout:25];("; + for (const GeoBounds &box : boxes) { + union_bounds = merge_bounds(union_bounds, box); + const std::string bbox = bbox_string(box); + query += "way[\"highway\"][\"area\"!=\"yes\"](" + bbox + ");"; + query += "way[\"natural\"=\"water\"](" + bbox + ");"; + query += "way[\"waterway\"=\"riverbank\"](" + bbox + ");"; + query += "way[\"waterway\"~\"river|stream|canal\"](" + bbox + ");"; + } + query += ");out tags geom;"; + + std::string key = bounds_key(union_bounds); + key += ":"; + key += std::to_string(boxes.size()); + for (const GeoBounds &box : boxes) { + key += ":"; + key += bbox_string(box); + } + return MapRequestSpec{ + .key = std::move(key), + .bounds = union_bounds, + .query = std::move(query), + }; +} + +bool fetch_overpass_json(std::string_view query, std::string *out) { + const std::string body = std::string("data=") + percent_encode(query); + for (const char *endpoint : MAP_QUERY_ENDPOINTS) { + const std::string command = "curl -fsSL --compressed --connect-timeout 8 --max-time 30 " + "-A 'jotpluggler-vector-map/1.0' " + "-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' " + "--data-raw " + shell_quote(body) + " " + + shell_quote(endpoint); + const std::string response = util::check_output(command); + if (!response.empty() && response.front() == '{') { + *out = response; + return true; + } + } + return false; +} + +std::string load_overpass_json(std::string_view query) { + std::string response; + if (!fetch_overpass_json(query, &response)) { + return {}; + } + return response; +} + +template +void append_pod(std::string *out, const T &value) { + const size_t start = out->size(); + out->resize(start + sizeof(T)); + std::memcpy(out->data() + start, &value, sizeof(T)); +} + +template +bool read_pod(std::string_view data, size_t *offset, T *value) { + if (*offset + sizeof(T) > data.size()) { + return false; + } + std::memcpy(value, data.data() + *offset, sizeof(T)); + *offset += sizeof(T); + return true; +} + +void append_points(std::string *out, const std::vector &points) { + const uint32_t count = static_cast(points.size()); + append_pod(out, count); + for (const ProjectedPoint &point : points) { + append_pod(out, point.x); + append_pod(out, point.y); + } +} + +bool read_points(std::string_view data, size_t *offset, std::vector *points) { + uint32_t count = 0; + if (!read_pod(data, offset, &count)) { + return false; + } + points->clear(); + points->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + ProjectedPoint point; + if (!read_pod(data, offset, &point.x) || !read_pod(data, offset, &point.y)) { + return false; + } + points->push_back(point); + } + return true; +} + +std::string serialize_basemap_payload(const RouteBasemap &basemap) { + std::string raw; + raw.reserve(1024 + basemap.roads.size() * 48); + raw.append("JBM2", 4); + append_pod(&raw, basemap.bounds.south); + append_pod(&raw, basemap.bounds.west); + append_pod(&raw, basemap.bounds.north); + append_pod(&raw, basemap.bounds.east); + + const uint32_t road_count = static_cast(basemap.roads.size()); + const uint32_t water_line_count = static_cast(basemap.water_lines.size()); + const uint32_t water_polygon_count = static_cast(basemap.water_polygons.size()); + append_pod(&raw, road_count); + append_pod(&raw, water_line_count); + append_pod(&raw, water_polygon_count); + + for (const RoadFeature &road : basemap.roads) { + const uint8_t kind = static_cast(road.road_class); + append_pod(&raw, kind); + append_points(&raw, road.points); + } + for (const WaterLineFeature &water : basemap.water_lines) { + append_points(&raw, water.points); + } + for (const WaterPolygonFeature &water : basemap.water_polygons) { + append_points(&raw, water.ring); + } + return raw; +} + +std::optional deserialize_basemap_payload(std::string_view raw, const std::string &key) { + if (!util::starts_with(std::string(raw), "JBM2")) { + return std::nullopt; + } + size_t offset = 4; + RouteBasemap basemap; + basemap.key = key; + if (!read_pod(raw, &offset, &basemap.bounds.south) + || !read_pod(raw, &offset, &basemap.bounds.west) + || !read_pod(raw, &offset, &basemap.bounds.north) + || !read_pod(raw, &offset, &basemap.bounds.east)) { + return std::nullopt; + } + basemap.projected_bounds = project_bounds0(basemap.bounds); + + uint32_t road_count = 0; + uint32_t water_line_count = 0; + uint32_t water_polygon_count = 0; + if (!read_pod(raw, &offset, &road_count) + || !read_pod(raw, &offset, &water_line_count) + || !read_pod(raw, &offset, &water_polygon_count)) { + return std::nullopt; + } + + basemap.roads.reserve(road_count); + for (uint32_t i = 0; i < road_count; ++i) { + uint8_t kind = 0; + std::vector points; + if (!read_pod(raw, &offset, &kind) || !read_points(raw, &offset, &points)) { + return std::nullopt; + } + basemap.roads.push_back(RoadFeature{ + .road_class = static_cast(kind), + .bounds = compute_projected_bounds(points), + .points = std::move(points), + }); + } + + basemap.water_lines.reserve(water_line_count); + for (uint32_t i = 0; i < water_line_count; ++i) { + std::vector points; + if (!read_points(raw, &offset, &points)) { + return std::nullopt; + } + basemap.water_lines.push_back(WaterLineFeature{ + .bounds = compute_projected_bounds(points), + .points = std::move(points), + }); + } + + basemap.water_polygons.reserve(water_polygon_count); + for (uint32_t i = 0; i < water_polygon_count; ++i) { + std::vector ring; + if (!read_points(raw, &offset, &ring)) { + return std::nullopt; + } + basemap.water_polygons.push_back(WaterPolygonFeature{ + .bounds = compute_projected_bounds(ring), + .ring = std::move(ring), + }); + } + return basemap; +} + +bool save_compressed_basemap(const fs::path &path, const RouteBasemap &basemap) { + const std::string raw = serialize_basemap_payload(basemap); + const size_t bound = ZSTD_compressBound(raw.size()); + std::string compressed(bound, '\0'); + const size_t size = ZSTD_compress(compressed.data(), compressed.size(), raw.data(), raw.size(), 5); + if (ZSTD_isError(size)) { + return false; + } + compressed.resize(size); + ensure_parent_dir(path); + const std::string path_string = path.string(); + return util::write_file(path_string.c_str(), compressed.data(), compressed.size(), O_WRONLY | O_CREAT | O_TRUNC) == 0; +} + +std::optional load_compressed_basemap(const fs::path &path, const std::string &key) { + const std::string compressed = util::read_file(path.string()); + if (compressed.empty()) { + return std::nullopt; + } + const unsigned long long raw_size = ZSTD_getFrameContentSize(compressed.data(), compressed.size()); + if (raw_size == ZSTD_CONTENTSIZE_ERROR || raw_size == ZSTD_CONTENTSIZE_UNKNOWN || raw_size > (1ULL << 31)) { + return std::nullopt; + } + std::string raw(static_cast(raw_size), '\0'); + const size_t actual = ZSTD_decompress(raw.data(), raw.size(), compressed.data(), compressed.size()); + if (ZSTD_isError(actual)) { + return std::nullopt; + } + raw.resize(actual); + return deserialize_basemap_payload(raw, key); +} + +std::vector geometry_points(const json11::Json &geometry_json) { + std::vector points; + const auto items = geometry_json.array_items(); + points.reserve(items.size()); + for (const json11::Json &point : items) { + if (!point["lat"].is_number() || !point["lon"].is_number()) { + continue; + } + points.push_back(ProjectedPoint{ + .x = project_lon0(point["lon"].number_value()), + .y = project_lat0(point["lat"].number_value()), + }); + } + return points; +} + +std::optional classify_road(std::string_view highway) { + if (highway == "motorway" || highway == "motorway_link" || highway == "trunk" || highway == "trunk_link") { + return RoadClass::Motorway; + } + if (highway == "primary" || highway == "primary_link") { + return RoadClass::Primary; + } + if (highway == "secondary" || highway == "secondary_link" || highway == "tertiary" || highway == "tertiary_link") { + return RoadClass::Secondary; + } + if (highway == "residential" || highway == "unclassified" || highway == "living_street" || highway == "road") { + return RoadClass::Local; + } + return std::nullopt; +} + +std::optional parse_basemap_json(const std::string &raw, const GeoBounds &bounds, const std::string &key) { + std::string parse_error; + const json11::Json root = json11::Json::parse(raw, parse_error); + if (!parse_error.empty() || !root.is_object()) { + return std::nullopt; + } + + RouteBasemap basemap; + basemap.key = key; + basemap.bounds = bounds; + basemap.projected_bounds = project_bounds0(bounds); + + for (const json11::Json &element : root["elements"].array_items()) { + if (element["type"].string_value() != "way") { + continue; + } + const json11::Json &tags = element["tags"]; + const std::vector points = geometry_points(element["geometry"]); + if (points.size() < 2) { + continue; + } + + const std::string highway = tags["highway"].string_value(); + if (!highway.empty()) { + const std::optional road_class = classify_road(highway); + if (!road_class.has_value()) { + continue; + } + basemap.roads.push_back(RoadFeature{ + .road_class = *road_class, + .bounds = compute_projected_bounds(points), + .points = points, + }); + continue; + } + + const std::string natural = tags["natural"].string_value(); + const std::string waterway = tags["waterway"].string_value(); + const bool closed = points.size() >= 4 + && std::abs(points.front().x - points.back().x) < 1.0e-6f + && std::abs(points.front().y - points.back().y) < 1.0e-6f; + if ((natural == "water" || waterway == "riverbank") && closed) { + basemap.water_polygons.push_back(WaterPolygonFeature{ + .bounds = compute_projected_bounds(points), + .ring = points, + }); + continue; + } + if (waterway == "river" || waterway == "stream" || waterway == "canal") { + basemap.water_lines.push_back(WaterLineFeature{ + .bounds = compute_projected_bounds(points), + .points = points, + }); + } + } + + return basemap; +} + +struct RoadPaint { + ImU32 casing = 0; + ImU32 fill = 0; + float casing_width = 1.0f; + float fill_width = 1.0f; +}; + +constexpr ImU32 MAP_BG_COLOR = IM_COL32(244, 243, 238, 255); +constexpr ImU32 MAP_WATER_FILL = IM_COL32(193, 216, 235, 185); +constexpr ImU32 MAP_WATER_OUTLINE = IM_COL32(143, 173, 201, 220); +constexpr ImU32 MAP_WATER_LINE = IM_COL32(156, 186, 214, 205); +constexpr ImU32 MAP_ROUTE_HALO = IM_COL32(31, 40, 50, 92); + +RoadPaint road_paint(RoadClass road_class, float zoom) { + const float scale = std::clamp(0.88f + 0.12f * (zoom - 12.0f), 0.76f, 1.95f); + switch (road_class) { + case RoadClass::Motorway: + return { + .casing = IM_COL32(163, 157, 149, 235), + .fill = IM_COL32(245, 235, 215, 255), + .casing_width = 5.6f * scale, + .fill_width = 3.7f * scale, + }; + case RoadClass::Primary: + return { + .casing = IM_COL32(171, 171, 168, 220), + .fill = IM_COL32(249, 246, 237, 248), + .casing_width = 4.6f * scale, + .fill_width = 2.95f * scale, + }; + case RoadClass::Secondary: + return { + .casing = IM_COL32(183, 186, 189, 210), + .fill = IM_COL32(252, 251, 247, 240), + .casing_width = 3.5f * scale, + .fill_width = 2.15f * scale, + }; + case RoadClass::Local: + default: + return { + .casing = IM_COL32(200, 202, 205, 195), + .fill = IM_COL32(255, 255, 254, 230), + .casing_width = 2.5f * scale, + .fill_width = 1.5f * scale, + }; + } +} + +void clamp_map_center(TabUiState::MapPaneState *map_state, const GeoBounds &bounds, const ImVec2 &size) { + if (!bounds.valid() || size.x <= 1.0f || size.y <= 1.0f) { + return; + } + const double zoom = map_state->zoom; + const double min_x = lon_to_world_x(bounds.west, zoom); + const double max_x = lon_to_world_x(bounds.east, zoom); + const double min_y = lat_to_world_y(bounds.north, zoom); + const double max_y = lat_to_world_y(bounds.south, zoom); + const double half_w = size.x * 0.5; + const double half_h = size.y * 0.5; + double center_x = lon_to_world_x(map_state->center_lon, zoom); + double center_y = lat_to_world_y(map_state->center_lat, zoom); + if (max_x - min_x <= size.x) { + center_x = (min_x + max_x) * 0.5; + } else { + center_x = std::clamp(center_x, min_x + half_w, max_x - half_w); + } + if (max_y - min_y <= size.y) { + center_y = (min_y + max_y) * 0.5; + } else { + center_y = std::clamp(center_y, min_y + half_h, max_y - half_h); + } + map_state->center_lon = world_x_to_lon(center_x, zoom); + map_state->center_lat = world_y_to_lat(center_y, zoom); +} + +void initialize_map_pane_state(TabUiState::MapPaneState *map_state, + const GpsTrace &trace, + const GeoBounds &bounds, + ImVec2 size, + SessionDataMode mode, + std::optional cursor_point) { + if (trace.points.empty()) { + return; + } + map_state->initialized = true; + map_state->follow = mode == SessionDataMode::Stream; + const int min_zoom = minimum_allowed_map_zoom(bounds, trace, size); + if (mode == SessionDataMode::Stream && cursor_point.has_value()) { + map_state->zoom = std::max(16.0f, static_cast(min_zoom)); + map_state->center_lat = cursor_point->lat; + map_state->center_lon = cursor_point->lon; + } else { + map_state->zoom = std::max(static_cast(fit_map_zoom_for_trace(trace, size.x, size.y)), + static_cast(min_zoom)); + map_state->center_lat = map_trace_center_lat(trace); + map_state->center_lon = map_trace_center_lon(trace); + } + clamp_map_center(map_state, bounds, size); +} + +void draw_feature_polyline(ImDrawList *draw_list, + const std::vector &points, + float zoom_scale, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min, + ImU32 color, + float thickness, + bool closed = false) { + if (points.size() < 2) { + return; + } + std::vector screen; + screen.reserve(points.size()); + for (const ProjectedPoint &point : points) { + screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast(top_left_x), + rect_min.y + point.y * zoom_scale - static_cast(top_left_y))); + } + draw_list->AddPolyline(screen.data(), static_cast(screen.size()), color, + closed ? ImDrawFlags_Closed : ImDrawFlags_None, thickness); +} + +void draw_water_polygon(ImDrawList *draw_list, + const WaterPolygonFeature &feature, + float zoom_scale, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min) { + if (feature.ring.size() < 3) { + return; + } + std::vector screen; + screen.reserve(feature.ring.size()); + for (const ProjectedPoint &point : feature.ring) { + screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast(top_left_x), + rect_min.y + point.y * zoom_scale - static_cast(top_left_y))); + } + if (screen.size() >= 3 && is_convex_ring(screen)) { + draw_list->AddConvexPolyFilled(screen.data(), static_cast(screen.size()), MAP_WATER_FILL); + } + draw_list->AddPolyline(screen.data(), static_cast(screen.size()), MAP_WATER_OUTLINE, + ImDrawFlags_Closed, 1.8f); +} + +void draw_edge_fade(ImDrawList *draw_list, + const GeoBounds &bounds, + double zoom, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min, + const ImVec2 &rect_max) { + if (!bounds.valid()) { + return; + } + + const float west_x = rect_min.x + static_cast(lon_to_world_x(bounds.west, zoom) - top_left_x); + const float east_x = rect_min.x + static_cast(lon_to_world_x(bounds.east, zoom) - top_left_x); + const float north_y = rect_min.y + static_cast(lat_to_world_y(bounds.north, zoom) - top_left_y); + const float south_y = rect_min.y + static_cast(lat_to_world_y(bounds.south, zoom) - top_left_y); + + const float fade_x = std::max(28.0f, (rect_max.x - rect_min.x) * MAP_EDGE_FADE_FRAC); + const float fade_y = std::max(28.0f, (rect_max.y - rect_min.y) * MAP_EDGE_FADE_FRAC); + const ImU32 solid = MAP_BG_COLOR; + const ImU32 clear = IM_COL32(244, 243, 238, 6); + + if (west_x > rect_min.x) { + const float x0 = rect_min.x; + const float x1 = std::min(rect_max.x, west_x); + const float xfade = std::max(x0, x1 - fade_x); + draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), solid, solid, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, clear, clear, solid); + } + if (east_x < rect_max.x) { + const float x0 = std::max(rect_min.x, east_x); + const float x1 = rect_max.x; + const float xfade = std::min(x1, x0 + fade_x); + draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), clear, solid, solid, clear); + draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, solid, solid, solid); + } + if (north_y > rect_min.y) { + const float y0 = rect_min.y; + const float y1 = std::min(rect_max.y, north_y); + const float yfade = std::max(y0, y1 - fade_y); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), solid, solid, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, clear, clear); + } + if (south_y < rect_max.y) { + const float y0 = std::max(rect_min.y, south_y); + const float y1 = rect_max.y; + const float yfade = std::min(y1, y0 + fade_y); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), clear, clear, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, solid, solid); + } +} + +} // namespace + +MapDataManager::MapDataManager() : worker_([this]() { run(); }) {} + +MapDataManager::~MapDataManager() { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + cv_.notify_all(); + if (worker_.joinable()) { + worker_.join(); + } +} + +void MapDataManager::pump() { + std::unique_ptr ready; + { + std::lock_guard lock(mutex_); + ready = std::move(completed_); + } + if (ready) { + current_ = std::move(ready); + } +} + +void MapDataManager::ensureTrace(const GpsTrace &trace) { + if (trace.points.empty()) { + return; + } + const MapRequestSpec wanted = build_request_for_trace(trace); + if (!wanted.bounds.valid()) { + return; + } + + std::lock_guard lock(mutex_); + if ((current_ && current_->key == wanted.key) || (pending_ && pending_->key == wanted.key)) { + return; + } + + if (const auto cached = load_compressed_basemap(basemap_cache_path(wanted.key), wanted.key)) { + current_ = std::make_unique(std::move(*cached)); + completed_.reset(); + pending_.reset(); + active_.reset(); + return; + } + + pending_ = std::make_unique(Request{ + .key = wanted.key, + .bounds = wanted.bounds, + .query = wanted.query, + }); + cv_.notify_one(); +} + +bool MapDataManager::loading() const { + std::lock_guard lock(mutex_); + return active_ || pending_; +} + +const RouteBasemap *MapDataManager::current() const { + return current_.get(); +} + +void MapDataManager::clearCache() { + std::lock_guard lock(mutex_); + clear_cache_directory(); +} + +MapCacheStats MapDataManager::cacheStats() const { + return MapCacheStats{ + .bytes = cache_directory_size_bytes(), + .files = cache_directory_file_count(), + }; +} + +void MapDataManager::run() { + while (true) { + Request request; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [&]() { return stopping_ || pending_ != nullptr; }); + if (stopping_) { + return; + } + request = *pending_; + active_ = std::move(pending_); + } + + std::unique_ptr parsed; + const std::string raw = load_overpass_json(request.query); + if (!raw.empty()) { + if (auto basemap = parse_basemap_json(raw, request.bounds, request.key)) { + save_compressed_basemap(basemap_cache_path(request.key), *basemap); + parsed = std::make_unique(std::move(*basemap)); + } + } + + { + std::lock_guard lock(mutex_); + if (active_ && active_->key == request.key) { + completed_ = std::move(parsed); + active_.reset(); + } + } + } +} + +void draw_map_pane(AppSession *session, UiState *state, Pane *, int pane_index) { + TabUiState *tab_state = app_active_tab_state(state); + if (tab_state == nullptr || pane_index < 0 || pane_index >= static_cast(tab_state->map_panes.size())) { + ImGui::TextUnformatted("Map unavailable"); + return; + } + if (!session->map_data) { + ImGui::TextUnformatted("Map unavailable"); + return; + } + + session->map_data->ensureTrace(session->route_data.gps_trace); + session->map_data->pump(); + + TabUiState::MapPaneState &map_state = tab_state->map_panes[static_cast(pane_index)]; + const GpsTrace &trace = session->route_data.gps_trace; + const RouteBasemap *basemap = session->map_data->current(); + const GeoBounds map_bounds = basemap != nullptr ? basemap->bounds : requested_bounds_for_trace(trace); + + const ImVec2 rect_min = ImGui::GetCursorScreenPos(); + const ImVec2 size = ImGui::GetContentRegionAvail(); + const ImVec2 input_size(std::max(1.0f, size.x - 22.0f), std::max(1.0f, size.y)); + ImGui::SetNextItemAllowOverlap(); + ImGui::InvisibleButton("##map_canvas", input_size); + const ImVec2 rect_max(rect_min.x + size.x, rect_min.y + size.y); + const float rect_width = rect_max.x - rect_min.x; + const float rect_height = rect_max.y - rect_min.y; + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + + draw_list->PushClipRect(rect_min, rect_max, true); + draw_list->AddRectFilled(rect_min, rect_max, MAP_BG_COLOR); + + if (trace.points.empty()) { + const char *label = session->async_route_loading ? "Loading map..." : "No GPS trace"; + const ImVec2 text = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(rect_min.x + (rect_width - text.x) * 0.5f, + rect_min.y + (rect_height - text.y) * 0.5f), + IM_COL32(110, 118, 128, 255), label); + draw_list->PopClipRect(); + return; + } + + const std::optional cursor_point = state->has_tracker_time + ? interpolate_gps(trace, state->tracker_time) + : std::optional{}; + if (!map_state.initialized) { + initialize_map_pane_state(&map_state, trace, map_bounds, size, session->data_mode, cursor_point); + } + + const int min_zoom = minimum_allowed_map_zoom(map_bounds, trace, size); + if (map_state.follow && cursor_point.has_value()) { + const float follow_zoom = std::clamp(map_state.zoom, static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + const double center_x = lon_to_world_x(map_state.center_lon, follow_zoom); + const double center_y = lat_to_world_y(map_state.center_lat, follow_zoom); + const double top_left_x = center_x - rect_width * 0.5; + const double top_left_y = center_y - rect_height * 0.5; + const ImVec2 car_screen = gps_to_screen(cursor_point->lat, cursor_point->lon, follow_zoom, top_left_x, top_left_y, rect_min); + if (!point_in_rect_with_margin(car_screen, rect_min, rect_max, 0.22f)) { + map_state.center_lat = cursor_point->lat; + map_state.center_lon = cursor_point->lon; + } + } + + map_state.zoom = std::clamp(map_state.zoom, static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + clamp_map_center(&map_state, map_bounds, size); + + const double zoom = map_state.zoom; + const float zoom_scale = static_cast(std::exp2(zoom)); + const double center_x = lon_to_world_x(map_state.center_lon, zoom); + const double center_y = lat_to_world_y(map_state.center_lat, zoom); + const double top_left_x = center_x - rect_width * 0.5; + const double top_left_y = center_y - rect_height * 0.5; + const ProjectedBounds current_view = view_bounds(top_left_x, top_left_y, rect_width, rect_height); + + if (basemap != nullptr) { + for (const WaterPolygonFeature &water : basemap->water_polygons) { + if (feature_intersects_view(water.bounds, current_view, zoom_scale)) { + draw_water_polygon(draw_list, water, zoom_scale, top_left_x, top_left_y, rect_min); + } + } + for (const WaterLineFeature &water : basemap->water_lines) { + if (feature_intersects_view(water.bounds, current_view, zoom_scale)) { + draw_feature_polyline(draw_list, water.points, zoom_scale, top_left_x, top_left_y, rect_min, + MAP_WATER_LINE, 2.4f); + } + } + + std::array order = { + RoadClass::Local, + RoadClass::Secondary, + RoadClass::Primary, + RoadClass::Motorway, + }; + for (RoadClass road_class : order) { + const RoadPaint paint = road_paint(road_class, static_cast(zoom)); + for (const RoadFeature &road : basemap->roads) { + if (road.road_class != road_class || !feature_intersects_view(road.bounds, current_view, zoom_scale)) { + continue; + } + draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min, + paint.casing, paint.casing_width); + draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min, + paint.fill, paint.fill_width); + } + } + } + + if (basemap != nullptr) { + draw_edge_fade(draw_list, basemap->bounds, zoom, top_left_x, top_left_y, rect_min, rect_max); + } + + for (size_t i = 1; i < trace.points.size(); ++i) { + const GpsPoint &p0 = trace.points[i - 1]; + const GpsPoint &p1 = trace.points[i]; + const ImVec2 s0 = gps_to_screen(p0.lat, p0.lon, zoom, top_left_x, top_left_y, rect_min); + const ImVec2 s1 = gps_to_screen(p1.lat, p1.lon, zoom, top_left_x, top_left_y, rect_min); + draw_list->AddLine(s0, s1, MAP_ROUTE_HALO, 5.8f); + draw_list->AddLine(s0, s1, map_timeline_color(p1.type, 1.0f), 3.25f); + } + + if (cursor_point.has_value()) { + const ImVec2 marker = gps_to_screen(cursor_point->lat, cursor_point->lon, zoom, top_left_x, top_left_y, rect_min); + const float marker_size = std::clamp(9.0f + 1.0f * static_cast(zoom - min_zoom), 9.0f, 20.0f); + draw_car_marker(draw_list, marker, cursor_point->bearing, map_timeline_color(cursor_point->type, 1.0f), marker_size); + } + + if (session->map_data->loading()) { + const char *label = basemap != nullptr ? "Refreshing roads..." : "Loading roads..."; + const ImVec2 text = ImGui::CalcTextSize(label); + const ImVec2 pos(rect_min.x + 12.0f, rect_max.y - text.y - 12.0f); + draw_list->AddRectFilled(ImVec2(pos.x - 6.0f, pos.y - 4.0f), + ImVec2(pos.x + text.x + 6.0f, pos.y + text.y + 4.0f), + IM_COL32(255, 255, 255, 180), 4.0f); + draw_list->AddText(pos, IM_COL32(84, 93, 105, 255), label); + } + draw_list->PopClipRect(); + + const bool canvas_hovered = ImGui::IsItemHovered(); + const bool double_clicked = canvas_hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + bool overlay_hovered = false; + if (const std::string google_maps_url = route_google_maps_url(trace); !google_maps_url.empty()) { + std::string label = std::string("Google Maps ") + icon::BOX_ARROW_UP_RIGHT; + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 button_size(text_size.x + 20.0f, text_size.y + 10.0f); + const ImVec2 button_pos(rect_max.x - button_size.x - 28.0f, rect_min.y + 10.0f); + ImGui::SetCursorScreenPos(button_pos); + ImGui::SetNextItemAllowOverlap(); + if (ImGui::Button("##open_google_maps", button_size)) { + open_external_url(google_maps_url); + state->status_text = "Opened Google Maps"; + } + overlay_hovered = ImGui::IsItemHovered(); + draw_list->AddText(ImVec2(button_pos.x + 10.0f, button_pos.y + (button_size.y - text_size.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), label.c_str()); + } + const bool hovered = canvas_hovered && !overlay_hovered; + if (hovered && ImGui::GetIO().MouseWheel != 0.0f) { + const float next_zoom = std::clamp(static_cast(zoom) + ImGui::GetIO().MouseWheel * MAP_WHEEL_ZOOM_STEP, + static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + if (std::abs(next_zoom - zoom) > 1.0e-4f) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + const double mouse_world_x = top_left_x + (mouse.x - rect_min.x); + const double mouse_world_y = top_left_y + (mouse.y - rect_min.y); + const double mouse_lon = world_x_to_lon(mouse_world_x, zoom); + const double mouse_lat = world_y_to_lat(mouse_world_y, zoom); + const double next_center_x = lon_to_world_x(mouse_lon, next_zoom) - (mouse.x - rect_min.x) + rect_width * 0.5; + const double next_center_y = lat_to_world_y(mouse_lat, next_zoom) - (mouse.y - rect_min.y) + rect_height * 0.5; + map_state.zoom = next_zoom; + map_state.center_lon = world_x_to_lon(next_center_x, next_zoom); + map_state.center_lat = world_y_to_lat(next_center_y, next_zoom); + map_state.follow = false; + clamp_map_center(&map_state, map_bounds, size); + } + } + if (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 2.0f)) { + const ImVec2 delta = ImGui::GetIO().MouseDelta; + const double next_center_x = center_x - delta.x; + const double next_center_y = center_y - delta.y; + map_state.center_lon = world_x_to_lon(next_center_x, zoom); + map_state.center_lat = world_y_to_lat(next_center_y, zoom); + map_state.follow = false; + clamp_map_center(&map_state, map_bounds, size); + } else if (hovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + const ImVec2 drag_delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left); + if (drag_delta.x * drag_delta.x + drag_delta.y * drag_delta.y < 16.0f) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + double best_dist = std::numeric_limits::max(); + double best_time = state->tracker_time; + for (const GpsPoint &point : trace.points) { + const ImVec2 screen = gps_to_screen(point.lat, point.lon, zoom, top_left_x, top_left_y, rect_min); + const double dx = static_cast(screen.x - mouse.x); + const double dy = static_cast(screen.y - mouse.y); + const double dist = dx * dx + dy * dy; + if (dist < best_dist) { + best_dist = dist; + best_time = point.time; + } + } + state->tracker_time = best_time; + state->has_tracker_time = true; + } + ImGui::ResetMouseDragDelta(ImGuiMouseButton_Left); + } + if (double_clicked) { + map_state.initialized = false; + } +} diff --git a/tools/jotpluggler/map.h b/tools/jotpluggler/map.h new file mode 100644 index 000000000..97473f1ba --- /dev/null +++ b/tools/jotpluggler/map.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct GpsTrace; +struct GeoBounds { + double south = 0.0; + double west = 0.0; + double north = 0.0; + double east = 0.0; + + bool valid() const { + return south < north && west < east; + } +}; + +struct RouteBasemap; +struct MapCacheStats { + uint64_t bytes = 0; + size_t files = 0; +}; + +class MapDataManager { +public: + MapDataManager(); + ~MapDataManager(); + + MapDataManager(const MapDataManager &) = delete; + MapDataManager &operator=(const MapDataManager &) = delete; + + void pump(); + void ensureTrace(const GpsTrace &trace); + void clearCache(); + bool loading() const; + const RouteBasemap *current() const; + MapCacheStats cacheStats() const; + +private: + struct Request { + std::string key; + GeoBounds bounds; + std::string query; + }; + + void run(); + + mutable std::mutex mutex_; + std::condition_variable cv_; + bool stopping_ = false; + std::unique_ptr pending_; + std::unique_ptr active_; + std::unique_ptr completed_; + std::unique_ptr current_; + std::thread worker_; +}; diff --git a/tools/jotpluggler/math_eval.py b/tools/jotpluggler/math_eval.py new file mode 100755 index 000000000..a865c88a3 --- /dev/null +++ b/tools/jotpluggler/math_eval.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +import json +import sys +import textwrap +import traceback + +import numpy as np + + +def _load_manifest(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _load_vector(path: str) -> np.ndarray: + return np.fromfile(path, dtype=np.float64) + + +def _write_vector(path: str, values: np.ndarray) -> None: + np.asarray(values, dtype=np.float64).tofile(path) + + +def _resample_to_reference(ref_t: np.ndarray, src_t: np.ndarray, src_v: np.ndarray) -> np.ndarray: + ref_t = np.asarray(ref_t, dtype=np.float64).reshape(-1) + src_t = np.asarray(src_t, dtype=np.float64).reshape(-1) + src_v = np.asarray(src_v, dtype=np.float64).reshape(-1) + if ref_t.size == 0 or src_t.size == 0 or src_v.size == 0: + return np.empty_like(ref_t) + indices = np.searchsorted(src_t, ref_t, side="right") - 1 + indices = np.clip(indices, 0, src_v.size - 1) + return src_v[indices] + + +def _evaluate_user_code(code: str, env: dict): + stripped = code.strip() + if not stripped: + raise ValueError("Function body is empty") + + expr = stripped + if expr.startswith("return "): + expr = expr[7:].strip() + try: + return eval(expr, env, env) + except SyntaxError: + pass + + function_src = "def __jotpluggler_eval__():\n" + textwrap.indent(code, " ") + exec(function_src, env, env) + return env["__jotpluggler_eval__"]() + + +def main() -> int: + if len(sys.argv) != 6: + print("usage: math_eval.py ", file=sys.stderr) + return 2 + + manifest_path, globals_path, code_path, out_t_path, out_v_path = sys.argv[1:6] + manifest = _load_manifest(manifest_path) + + series_t = {} + series_v = {} + for entry in manifest.get("series", []): + path = entry["path"] + series_t[path] = _load_vector(entry["t"]) + series_v[path] = _load_vector(entry["v"]) + + first_path = manifest.get("linked_source") or None + + def remember(path: str) -> None: + nonlocal first_path + if first_path is None: + first_path = path + + def t(path: str) -> np.ndarray: + remember(path) + return series_t[path] + + def v(path: str) -> np.ndarray: + remember(path) + return series_v[path] + + additional_sources = list(manifest.get("additional_sources", [])) + linked_source = manifest.get("linked_source") or "" + paths = list(manifest.get("paths", [])) + + env = { + "__builtins__": __builtins__, + "np": np, + "t": t, + "v": v, + "paths": paths, + "linked_source": linked_source, + "additional_sources": additional_sources, + } + + reference_time = None + if linked_source: + reference_time = series_t[linked_source] + env["time"] = reference_time + env["value"] = series_v[linked_source] + + for i, path in enumerate(additional_sources, start=1): + if reference_time is None: + env[f"t{i}"] = series_t[path] + env[f"v{i}"] = series_v[path] + else: + env[f"t{i}"] = reference_time + env[f"v{i}"] = _resample_to_reference(reference_time, series_t[path], series_v[path]) + + with open(globals_path, encoding="utf-8") as f: + globals_code = f.read() + if globals_code.strip(): + exec(globals_code, env, env) + + with open(code_path, encoding="utf-8") as f: + user_code = f.read() + result = _evaluate_user_code(user_code, env) + + if isinstance(result, tuple) and len(result) == 2: + result_t, result_v = result + else: + if first_path is None: + raise ValueError("No reference series found. Set an input timeseries or return (times, values).") + result_t = series_t[first_path] + result_v = result + + result_t = np.asarray(result_t, dtype=np.float64).reshape(-1) + result_v = np.asarray(result_v, dtype=np.float64).reshape(-1) + if result_t.size == 0 or result_v.size == 0: + raise ValueError("Custom series returned an empty result") + if result_t.shape != result_v.shape: + raise ValueError(f"Time/value arrays must have the same shape, got {result_t.shape} and {result_v.shape}") + + _write_vector(out_t_path, result_t) + _write_vector(out_v_path, result_v) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as err: + traceback.print_exc() + raise SystemExit(1) from err diff --git a/tools/jotpluggler/plot.cc b/tools/jotpluggler/plot.cc new file mode 100644 index 000000000..a3c68ddce --- /dev/null +++ b/tools/jotpluggler/plot.cc @@ -0,0 +1,1027 @@ +#include "tools/jotpluggler/internal.h" + +#include "implot.h" +#include "imgui_internal.h" + +#include +#include +#include +#include + +constexpr double PLOT_Y_PAD_FRACTION = 0.4; + +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 PaneEnumContext { + std::vector enums; +}; + +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; +} + +bool curve_is_state_like(const PreparedCurve &curve) { + if (!curve.display_info.integer_like || curve.xs.size() < 2 || curve.xs.size() != curve.ys.size()) { + return false; + } + if (curve.enum_info != nullptr) { + return true; + } + std::unordered_set distinct_values; + for (double value : curve.ys) { + if (!std::isfinite(value)) { + continue; + } + distinct_values.insert(static_cast(std::llround(value))); + if (distinct_values.size() > 12) { + return false; + } + } + return !distinct_values.empty(); +} + +bool curves_use_state_blocks(const std::vector &prepared_curves) { + if (prepared_curves.empty()) { + return false; + } + for (const PreparedCurve &curve : prepared_curves) { + if (!curve_is_state_like(curve)) { + 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_enum_axis_tick(double value, char *buf, int size, void *user_data) { + const auto *ctx = static_cast(user_data); + const int idx = static_cast(std::llround(value)); + if (ctx != nullptr && idx >= 0 && std::abs(value - static_cast(idx)) < 0.01) { + std::vector names; + names.reserve(ctx->enums.size()); + for (const EnumInfo *info : ctx->enums) { + if (info == nullptr || static_cast(idx) >= info->names.size()) { + continue; + } + const std::string &name = info->names[static_cast(idx)]; + if (name.empty()) continue; + if (std::find(names.begin(), names.end(), std::string_view(name)) == names.end()) { + names.emplace_back(name); + } + } + if (!names.empty()) { + std::string joined; + for (size_t i = 0; i < names.size(); ++i) { + if (i != 0) { + joined += ", "; + } + joined += names[i]; + } + return std::snprintf(buf, size, "%d (%s)", idx, joined.c_str()); + } + } + return std::snprintf(buf, size, "%.6g", value); +} + +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_PAD_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); + PaneEnumContext enum_context; + PaneValueFormatContext pane_value_format; + const bool state_block_mode = curves_use_state_blocks(prepared_curves); + bool all_enum_curves = !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) { + enum_context.enums.push_back(curve.enum_info); + } else { + all_enum_curves = false; + merge_pane_value_format(&pane_value_format, curve.display_info); + } + } + if (prepared_curves.empty()) { + all_enum_curves = false; + } + 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 (all_enum_curves && !enum_context.enums.empty()) { + ImPlot::SetupAxisFormat(ImAxis_Y1, format_enum_axis_tick, &enum_context); + } 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(i); + 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; + } else if (icon_menu_item(icon::ARROW_LEFT_RIGHT, "Zoom Out Horizontally", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::ResetHorizontal; + } else if (icon_menu_item(icon::ARROW_DOWN_UP, "Zoom Out Vertically", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::ResetVertical; + } + 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::ARROW_LEFT_RIGHT, "Flip Horizontal Axis", nullptr, false, false); + icon_menu_item(icon::ARROW_DOWN_UP, "Flip Vertical Axis", nullptr, false, false); + ImGui::Separator(); + icon_menu_item(icon::FILES, "Copy", nullptr, false, false); + icon_menu_item(icon::CLIPBOARD2, "Paste", nullptr, false, false); + 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::Separator(); + if (icon_menu_item(icon::X_SQUARE, "Close Pane")) { + action.kind = PaneMenuActionKind::Close; + } + ImGui::EndPopup(); + if (action.kind == PaneMenuActionKind::None) return std::nullopt; + return action; +} diff --git a/tools/jotpluggler/render.cc b/tools/jotpluggler/render.cc new file mode 100644 index 000000000..54f0c16cc --- /dev/null +++ b/tools/jotpluggler/render.cc @@ -0,0 +1,173 @@ +#include "tools/jotpluggler/internal.h" + +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" + +#include + +namespace fs = std::filesystem; + +void draw_fps_overlay(const UiState &state, float top_offset) { + if (!state.show_fps_overlay) { + return; + } + ImGuiViewport *viewport = ImGui::GetMainViewport(); + const ImGuiIO &io = ImGui::GetIO(); + const float fps = io.Framerate; + const std::string label = util::string_format("%.1f fps", fps); + + const ImVec2 padding(10.0f, 8.0f); + const ImVec2 margin(12.0f, 10.0f); + app_push_mono_font(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + app_pop_mono_font(); + const ImVec2 size(text_size.x + padding.x * 2.0f, text_size.y + padding.y * 2.0f); + const ImVec2 pos(viewport->Pos.x + viewport->Size.x - size.x - margin.x, + viewport->Pos.y + top_offset + margin.y); + ImDrawList *draw_list = ImGui::GetForegroundDrawList(viewport); + const ImVec2 max(pos.x + size.x, pos.y + size.y); + draw_list->AddRectFilled(pos, max, ImGui::GetColorU32(color_rgb(248, 249, 251, 0.92f)), 4.0f); + draw_list->AddRect(pos, max, ImGui::GetColorU32(color_rgb(182, 188, 196, 0.95f)), 4.0f); + draw_list->AddText(font, font_size, ImVec2(pos.x + padding.x, pos.y + padding.y), + ImGui::GetColorU32(color_rgb(57, 62, 69)), label.c_str(), nullptr); +} + +void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { + if (!state->fps_overlay_initialized) { + state->show_fps_overlay = false; + state->fps_overlay_initialized = true; + } + ensure_shared_range(state, *session); + if (state->follow_latest) { + update_follow_range(state, *session); + state->suppress_range_side_effects = true; + } else { + clamp_shared_range(state, *session); + } + const bool ctrl = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; + const bool shift = ImGui::GetIO().KeyShift; + if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { + if (shift) { + apply_redo(session, state); + } else { + apply_undo(session, state); + } + } + if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { + state->open_find_signal = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) { + step_tracker(state, -1.0); + } + if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) { + step_tracker(state, 1.0); + } + if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) { + state->playback_playing = !state->playback_playing; + } + advance_playback(state, *session); + CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); + if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) { + sidebar_camera->update(state->tracker_time); + } + const float menu_height = draw_main_menu_bar(session, state); + UiMetrics ui = compute_ui_metrics(ImGui::GetMainViewport()->Size, menu_height, state->sidebar_width); + if (state->browser_nodes_dirty) { + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } + state->sidebar_width = ui.sidebar_width; + draw_sidebar(session, ui, state, show_camera_feed); + draw_workspace(session, ui, state); + draw_sidebar_resizer(ui, state); + if (!state->custom_series.selected && !state->logs.selected) { + draw_pane_windows(session, state); + } + draw_status_bar(*session, ui, state); + draw_popups(session, state); + draw_fps_overlay(*state, menu_height); +} + +void save_framebuffer_png(const fs::path &output_path, int width, int height) { + ensure_parent_dir(output_path); + if (width <= 0 || height <= 0) throw std::runtime_error("Invalid framebuffer size"); + + std::vector pixels(static_cast(width) * static_cast(height) * 4U, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + + const fs::path ppm_path = output_path.parent_path() / (output_path.stem().string() + ".ppm"); + std::string ppm = util::string_format("P6\n%d %d\n255\n", width, height); + ppm.reserve(ppm.size() + static_cast(width) * static_cast(height) * 3U); + for (int y = height - 1; y >= 0; --y) { + for (int x = 0; x < width; ++x) { + const size_t index = static_cast((y * width + x) * 4); + ppm.append(reinterpret_cast(&pixels[index]), 3); + } + } + write_file_or_throw(ppm_path, ppm.data(), ppm.size()); + + const std::string command = "convert " + shell_quote(ppm_path.string()) + " " + shell_quote(output_path.string()); + run_system_or_throw(command, "image conversion"); + fs::remove(ppm_path); +} + +void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const fs::path *capture_path) { + glfwPollEvents(); + + int framebuffer_width = 0; + int framebuffer_height = 0; + glfwGetFramebufferSize(window, &framebuffer_width, &framebuffer_height); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + + if (state->request_save_layout) { + if (session->layout_path.empty()) { + state->open_save_layout = true; + } else { + save_layout(session, state, session->layout_path.string()); + } + state->request_save_layout = false; + } + if (state->request_reset_layout) { + reset_layout(session, state); + state->request_reset_layout = false; + } + poll_async_route_load(session, state); + if (session->data_mode == SessionDataMode::Stream && session->stream_poller) { + StreamExtractBatch batch; + std::string error_text; + if (session->stream_poller->consume(&batch, &error_text)) { + if (!error_text.empty()) { + state->error_text = error_text; + state->open_error_popup = true; + state->status_text = "Stream disconnected"; + } else { + apply_stream_batch(session, state, std::move(batch)); + } + } + } + + const bool show_camera = capture_path == nullptr && session->data_mode != SessionDataMode::Stream; + render_layout(session, state, show_camera); + ImGui::Render(); + if (state->request_close) { + glfwSetWindowShouldClose(window, GLFW_TRUE); + state->request_close = false; + } + + glViewport(0, 0, framebuffer_width, framebuffer_height); + glClearColor(227.0f / 255.0f, 229.0f / 255.0f, 233.0f / 255.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + if (capture_path != nullptr) { + save_framebuffer_png(*capture_path, framebuffer_width, framebuffer_height); + } + glfwSwapBuffers(window); + state->suppress_range_side_effects = false; +} diff --git a/tools/jotpluggler/runtime.cc b/tools/jotpluggler/runtime.cc new file mode 100644 index 000000000..47344c551 --- /dev/null +++ b/tools/jotpluggler/runtime.cc @@ -0,0 +1,1280 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include "cereal/services.h" +#include "common/timing.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include "libyuv.h" +#include "msgq_repo/msgq/ipc.h" +#include "tools/replay/framereader.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "system/camerad/cameras/nv12_info.h" + +namespace { + +std::atomic g_glfw_alive{false}; +const bool kLogCameraTimings = env_flag_enabled("JOTP_CAMERA_TIMINGS"); + +CameraType decoder_camera_type(CameraViewKind view) { + switch (view) { + case CameraViewKind::Driver: return DriverCam; + case CameraViewKind::WideRoad: return WideRoadCam; + case CameraViewKind::QRoad: return RoadCam; + case CameraViewKind::Road: + default: return RoadCam; + } +} + +bool stream_batch_has_data(const StreamExtractBatch &batch) { + return !batch.series.empty() + || !batch.can_messages.empty() + || !batch.logs.empty() + || !batch.timeline.empty() + || !batch.enum_info.empty() + || !batch.car_fingerprint.empty() + || !batch.dbc_name.empty(); +} + +bool should_subscribe_stream_service(const std::string &name) { + static const std::array kSkippedServices = {{ + "roadEncodeIdx", + "driverEncodeIdx", + "wideRoadEncodeIdx", + "qRoadEncodeIdx", + "roadEncodeData", + "driverEncodeData", + "wideRoadEncodeData", + "qRoadEncodeData", + "livestreamWideRoadEncodeIdx", + "livestreamRoadEncodeIdx", + "livestreamDriverEncodeIdx", + "thumbnail", + "navThumbnail", + }}; + if (name == "rawAudioData") return false; + for (std::string_view skipped : kSkippedServices) { + if (name == skipped) return false; + } + return true; +} + +void glfw_error_callback(int error, const char *description) { + const std::string_view desc = description != nullptr ? description : "unknown"; + if (error == 65539 && desc.find("Invalid window attribute 0x0002000D") != std::string_view::npos) { + return; + } + std::cerr << "GLFW error " << error << ": " << desc << "\n"; +} + +} // namespace + +GlfwRuntime::GlfwRuntime(const Options &options) { + glfwSetErrorCallback(glfw_error_callback); + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + g_glfw_alive.store(true); + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + const bool fixed_size = !options.show; + glfwWindowHint(GLFW_RESIZABLE, fixed_size ? GLFW_FALSE : GLFW_TRUE); + glfwWindowHint(GLFW_VISIBLE, options.show ? GLFW_TRUE : GLFW_FALSE); + + window_ = glfwCreateWindow(options.width, options.height, "jotpluggler", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + + if (fixed_size) { + glfwSetWindowSizeLimits(window_, options.width, options.height, options.width, options.height); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(options.show ? 1 : 0); +} + +GlfwRuntime::~GlfwRuntime() { + if (window_ != nullptr) { + glfwDestroyWindow(window_); + } + g_glfw_alive.store(false); + glfwTerminate(); +} + +GLFWwindow *GlfwRuntime::window() const { + return window_; +} + +ImGuiRuntime::ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } +} + +ImGuiRuntime::~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); +} + +struct TerminalRouteProgress::Impl { + explicit Impl(bool enabled) : enabled_(enabled) {} + + void update(const RouteLoadProgress &progress) { + std::lock_guard lock(mutex_); + if (!enabled_) { + return; + } + + double overall = 0.0; + std::string detail = "Resolving route"; + if (progress.stage == RouteLoadStage::Finished) { + overall = 1.0; + detail = "Ready"; + } else if (progress.total_segments > 0) { + const bool finalizing = progress.segments_downloaded >= progress.total_segments + && progress.segments_parsed >= progress.total_segments; + if (finalizing) { + overall = 0.99; + detail = "Finalizing route data"; + } else { + const double total_work = static_cast(progress.total_segments) * 2.0; + const double complete_work = static_cast(progress.segments_downloaded + progress.segments_parsed); + overall = total_work <= 0.0 ? 0.0 : std::clamp(complete_work / total_work, 0.0, 0.99); + std::ostringstream desc; + desc << "Downloaded " << progress.segments_downloaded << "/" << progress.total_segments + << " Parsed " << progress.segments_parsed << "/" << progress.total_segments; + detail = desc.str(); + } + } + + render(overall, detail); + } + + void finish() { + std::lock_guard lock(mutex_); + if (!enabled_ || !rendered_) { + return; + } + render(1.0, "Ready"); + std::fputc('\n', stderr); + std::fflush(stderr); + rendered_ = false; + } + + void render(double progress, const std::string &detail) { + const int percent = std::clamp(static_cast(std::round(progress * 100.0)), 0, 100); + if (percent == last_percent_ && detail == last_detail_) { + return; + } + + constexpr int kWidth = 20; + const int filled = std::clamp(static_cast(std::round(progress * kWidth)), 0, kWidth); + const std::string bar = std::string(static_cast(filled), '=') + std::string(static_cast(kWidth - filled), ' '); + std::ostringstream line; + line << "\r[" << bar << "] " << percent << "% " << detail; + + const std::string text = line.str(); + std::fprintf(stderr, "%s", text.c_str()); + if (text.size() < last_line_width_) { + std::fprintf(stderr, "%s", std::string(last_line_width_ - text.size(), ' ').c_str()); + } + std::fflush(stderr); + + rendered_ = true; + last_percent_ = percent; + last_detail_ = detail; + last_line_width_ = text.size(); + } + + bool enabled_ = false; + bool rendered_ = false; + int last_percent_ = -1; + size_t last_line_width_ = 0; + std::string last_detail_; + std::mutex mutex_; +}; + +TerminalRouteProgress::TerminalRouteProgress(bool enabled) + : impl_(std::make_unique(enabled)) {} + +TerminalRouteProgress::~TerminalRouteProgress() { + finish(); +} + +void TerminalRouteProgress::update(const RouteLoadProgress &progress) { + impl_->update(progress); +} + +void TerminalRouteProgress::finish() { + impl_->finish(); +} + +struct AsyncRouteLoader::Impl { + explicit Impl(bool enable_terminal_progress) + : terminal_progress(enable_terminal_progress) {} + + ~Impl() { + join(); + } + + void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value) { + join(); + { + std::lock_guard lock(mutex); + route_name = route_name_value; + data_dir = data_dir_value; + dbc_name = dbc_name_value; + result.reset(); + error_text.clear(); + } + active.store(!route_name_value.empty()); + completed.store(route_name_value.empty()); + success.store(route_name_value.empty()); + total_segments.store(0); + segments_downloaded.store(0); + segments_parsed.store(0); + if (route_name_value.empty()) { + return; + } + + worker = std::thread([this]() { + try { + RouteData route_data = load_route_data(route_name, data_dir, dbc_name, [this](const RouteLoadProgress &progress) { + total_segments.store(progress.total_segments > 0 ? progress.total_segments : progress.segment_count); + segments_downloaded.store(progress.segments_downloaded); + segments_parsed.store(progress.segments_parsed); + terminal_progress.update(progress); + }); + { + std::lock_guard lock(mutex); + result = std::make_unique(std::move(route_data)); + error_text.clear(); + } + success.store(true); + } catch (const std::exception &err) { + std::lock_guard lock(mutex); + result.reset(); + error_text = err.what(); + success.store(false); + } + active.store(false); + completed.store(true); + terminal_progress.finish(); + }); + } + + RouteLoadSnapshot snapshot() const { + RouteLoadSnapshot snapshot; + snapshot.active = active.load(); + snapshot.total_segments = total_segments.load(); + snapshot.segments_downloaded = segments_downloaded.load(); + snapshot.segments_parsed = segments_parsed.load(); + return snapshot; + } + + bool consume(RouteData *route_data, std::string *error_text_out) { + if (!completed.load()) return false; + join(); + std::lock_guard lock(mutex); + completed.store(false); + if (result) { + *route_data = std::move(*result); + result.reset(); + if (error_text_out != nullptr) { + error_text_out->clear(); + } + return true; + } + if (error_text_out != nullptr) { + *error_text_out = error_text; + } + return true; + } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + mutable std::mutex mutex; + std::thread worker; + std::unique_ptr result; + std::string route_name; + std::string data_dir; + std::string dbc_name; + std::string error_text; + std::atomic active{false}; + std::atomic completed{false}; + std::atomic success{false}; + std::atomic total_segments{0}; + std::atomic segments_downloaded{0}; + std::atomic segments_parsed{0}; + TerminalRouteProgress terminal_progress; +}; + +AsyncRouteLoader::AsyncRouteLoader(bool enable_terminal_progress) + : impl_(std::make_unique(enable_terminal_progress)) {} + +AsyncRouteLoader::~AsyncRouteLoader() = default; + +void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name) { + impl_->start(route_name, data_dir, dbc_name); +} + +RouteLoadSnapshot AsyncRouteLoader::snapshot() const { + return impl_->snapshot(); +} + +bool AsyncRouteLoader::consume(RouteData *route_data, std::string *error_text) { + return impl_->consume(route_data, error_text); +} + +struct StreamPoller::Impl { + ~Impl() { + stop(); + } + + void start(const StreamSourceConfig &requested_source, + double requested_buffer_seconds, + const std::string &dbc_name, + std::optional time_offset) { + stop(); + { + std::lock_guard lock(mutex); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + error_text.clear(); + source = requested_source; + if (source.kind == StreamSourceKind::CerealLocal) { + source.address = "127.0.0.1"; + } else if (source.kind == StreamSourceKind::CerealRemote) { + source.address = normalize_stream_address(source.address); + } + buffer_seconds = std::max(1.0, requested_buffer_seconds); + latest_dbc_name = dbc_name; + latest_car_fingerprint.clear(); + } + received_messages.store(0); + connected.store(false); + paused.store(false); + running.store(true); + worker = std::thread([this, dbc_name, time_offset]() { + try { + StreamAccumulator accumulator(dbc_name, time_offset); + switch (source.kind) { + case StreamSourceKind::CerealLocal: + case StreamSourceKind::CerealRemote: + run_cereal_source(&accumulator); + break; + } + } catch (const std::exception &err) { + std::lock_guard lock(mutex); + error_text = err.what(); + } + connected.store(false); + running.store(false); + }); + } + + void setPaused(bool paused_value) { + paused.store(paused_value); + if (paused_value) { + std::lock_guard lock(mutex); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + error_text.clear(); + } + } + + void set_error_text(std::string text) { + std::lock_guard lock(mutex); + error_text = std::move(text); + } + + void clear_error_text() { + std::lock_guard lock(mutex); + error_text.clear(); + } + + void stop() { + running.store(false); + paused.store(false); + if (worker.joinable()) { + worker.join(); + } + connected.store(false); + } + + StreamPollSnapshot snapshot() const { + StreamPollSnapshot out; + out.active = running.load(); + out.connected = connected.load(); + out.paused = paused.load(); + out.source_kind = source.kind; + out.source_label = stream_source_target_label(source); + out.buffer_seconds = buffer_seconds; + out.received_messages = received_messages.load(); + std::lock_guard lock(mutex); + out.dbc_name = latest_dbc_name; + out.car_fingerprint = latest_car_fingerprint; + return out; + } + + bool consume(StreamExtractBatch *batch, std::string *out_error_text) { + std::lock_guard lock(mutex); + const bool has_error = !error_text.empty(); + const bool has_batch = stream_batch_has_data(pending); + if (!has_error && !has_batch) return false; + if (batch != nullptr) { + *batch = std::move(pending); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + } + if (out_error_text != nullptr) { + *out_error_text = error_text; + error_text.clear(); + } + return true; + } + + static void merge_route_series(RouteSeries *dst, RouteSeries *src) { + if (src->times.empty()) { + return; + } + if (dst->path.empty()) { + dst->path = src->path; + } + dst->times.insert(dst->times.end(), src->times.begin(), src->times.end()); + dst->values.insert(dst->values.end(), src->values.begin(), src->values.end()); + } + + static void merge_can_message_data(CanMessageData *dst, CanMessageData *src) { + if (src->samples.empty()) { + return; + } + if (dst->samples.empty()) { + *dst = std::move(*src); + return; + } + dst->samples.insert(dst->samples.end(), + std::make_move_iterator(src->samples.begin()), + std::make_move_iterator(src->samples.end())); + } + + static void merge_batch(StreamExtractBatch *dst, + std::unordered_map *series_slots, + std::unordered_map *can_slots, + StreamExtractBatch *src) { + for (RouteSeries &series : src->series) { + auto [it, inserted] = series_slots->try_emplace(series.path, dst->series.size()); + if (inserted) { + dst->series.push_back(RouteSeries{.path = series.path}); + } + merge_route_series(&dst->series[it->second], &series); + } + for (CanMessageData &message : src->can_messages) { + auto [it, inserted] = can_slots->try_emplace(message.id, dst->can_messages.size()); + if (inserted) { + dst->can_messages.push_back(CanMessageData{.id = message.id}); + } + merge_can_message_data(&dst->can_messages[it->second], &message); + } + if (!src->logs.empty()) { + dst->logs.insert(dst->logs.end(), + std::make_move_iterator(src->logs.begin()), + std::make_move_iterator(src->logs.end())); + } + if (!src->timeline.empty()) { + dst->timeline.insert(dst->timeline.end(), + std::make_move_iterator(src->timeline.begin()), + std::make_move_iterator(src->timeline.end())); + } + for (auto &[path, info] : src->enum_info) { + dst->enum_info[path] = std::move(info); + } + if (!src->car_fingerprint.empty()) { + dst->car_fingerprint = src->car_fingerprint; + } + if (!src->dbc_name.empty()) { + dst->dbc_name = src->dbc_name; + } + } + + void publish_batch(StreamAccumulator *accumulator) { + StreamExtractBatch batch = accumulator->takeBatch(); + if (!stream_batch_has_data(batch)) { + return; + } + std::lock_guard lock(mutex); + merge_batch(&pending, &pending_series_slots, &pending_can_slots, &batch); + latest_dbc_name = pending.dbc_name; + latest_car_fingerprint = pending.car_fingerprint; + } + + void run_cereal_source(StreamAccumulator *accumulator) { + if (source.kind == StreamSourceKind::CerealRemote) { + setenv("ZMQ", "1", 1); + } else { + unsetenv("ZMQ"); + } + + std::unique_ptr context(Context::create()); + std::unique_ptr poller(Poller::create()); + std::vector> sockets; + sockets.reserve(services.size()); + for (const auto &[name, info] : services) { + if (!should_subscribe_stream_service(name)) continue; + std::unique_ptr socket( + SubSocket::create(context.get(), name.c_str(), source.address.c_str(), false, true, info.queue_size)); + if (socket == nullptr) continue; + socket->setTimeout(0); + poller->registerSocket(socket.get()); + sockets.push_back(std::move(socket)); + } + if (sockets.empty()) throw std::runtime_error("Failed to connect to any cereal service"); + connected.store(true); + + while (running.load()) { + std::vector ready = poller->poll(1); + for (SubSocket *socket : ready) { + while (running.load()) { + std::unique_ptr msg(socket->receive(true)); + if (!msg) break; + const size_t size = msg->getSize(); + if (size < sizeof(capnp::word) || (size % sizeof(capnp::word)) != 0) { + continue; + } + if (paused.load()) { + received_messages.fetch_add(1); + continue; + } + kj::ArrayPtr data(reinterpret_cast(msg->getData()), + size / sizeof(capnp::word)); + capnp::FlatArrayMessageReader event_reader(data); + const cereal::Event::Reader event = event_reader.getRoot(); + accumulator->appendEvent(event.which(), data); + received_messages.fetch_add(1); + } + } + publish_batch(accumulator); + } + } + + mutable std::mutex mutex; + std::thread worker; + std::atomic running{false}; + std::atomic connected{false}; + std::atomic paused{false}; + std::atomic received_messages{0}; + StreamExtractBatch pending; + std::unordered_map pending_series_slots; + std::unordered_map pending_can_slots; + std::string error_text; + StreamSourceConfig source; + std::string latest_dbc_name; + std::string latest_car_fingerprint; + double buffer_seconds = 30.0; +}; + +StreamPoller::StreamPoller() + : impl_(std::make_unique()) {} + +StreamPoller::~StreamPoller() = default; + +void StreamPoller::start(const StreamSourceConfig &source, + double buffer_seconds, + const std::string &dbc_name, + std::optional time_offset) { + impl_->start(source, buffer_seconds, dbc_name, time_offset); +} + +void StreamPoller::setPaused(bool paused) { + impl_->setPaused(paused); +} + +void StreamPoller::stop() { + impl_->stop(); +} + +StreamPollSnapshot StreamPoller::snapshot() const { + return impl_->snapshot(); +} + +bool StreamPoller::consume(StreamExtractBatch *batch, std::string *error_text) { + return impl_->consume(batch, error_text); +} + +struct CameraFeedView::Impl { + struct RequestKey { + int segment = -1; + int decode_index = -1; + }; + + struct DecodeRequest { + RequestKey key; + std::string path; + uint64_t serial = 0; + uint64_t generation = 0; + }; + + struct PreloadTask { + int segment = -1; + std::string path; + uint64_t generation = 0; + }; + + struct DecodeResult { + RequestKey key; + bool success = false; + int width = 0; + int height = 0; + double decode_ms = 0.0; + std::vector rgba; + }; + + static constexpr float kDefaultAspect = 1208.0f / 1928.0f; + static constexpr size_t kCachedFrames = 8; + static constexpr int kPrefetchAhead = 2; + static constexpr int kImmediateNearbyFrameDistance = 8; + static constexpr int kPreloadWorkerCount = 2; + + Impl() { + demand_worker = std::thread([this]() { demand_worker_loop(); }); + for (int i = 0; i < kPreloadWorkerCount; ++i) { + preload_workers.emplace_back([this]() { preload_worker_loop(); }); + } + } + + ~Impl() { + stop.store(true); + cv.notify_all(); + if (demand_worker.joinable()) { + demand_worker.join(); + } + for (std::thread &worker : preload_workers) { + if (worker.joinable()) { + worker.join(); + } + } + destroy_texture(); + } + + void setRouteData(const RouteData &route_data) { + setCameraIndex(route_data.road_camera, CameraViewKind::Road); + } + + void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view) { + destroy_texture(); + { + std::lock_guard lock(mutex); + route_index = camera_index; + camera_view = view; + pending_request.reset(); + pending_result.reset(); + cached_results.clear(); + preload_queue.clear(); + preload_focus_segment = -1; + ++route_generation; + latest_request_serial = 0; + int initial_focus_segment = -1; + if (!route_index.entries.empty()) { + initial_focus_segment = route_index.entries.front().segment; + } else { + for (const CameraSegmentFile &segment_file : route_index.segment_files) { + if (!segment_file.path.empty()) { + initial_focus_segment = segment_file.segment; + break; + } + } + } + if (initial_focus_segment >= 0) { + rebuild_preload_queue_locked(initial_focus_segment); + } + } + abort_demand_work.store(true); + abort_preload_work.store(true); + active_request.reset(); + displayed_request.reset(); + failed_request.reset(); + frame_width = 0; + frame_height = 0; + cv.notify_all(); + } + + void update(double tracker_time) { + upload_pending_result(); + std::optional request = request_for_time(tracker_time); + if (!request.has_value()) { + return; + } + if (same_request(active_request, request->key) || same_request(displayed_request, request->key) || + same_request(failed_request, request->key)) { + return; + } + if (try_upload_cached_result(request->key)) { + return; + } + try_upload_nearby_cached_result(request->key); + + bool focus_changed = false; + { + std::lock_guard lock(mutex); + if (preload_focus_segment != request->key.segment) { + rebuild_preload_queue_locked(request->key.segment); + focus_changed = true; + } + request->serial = ++latest_request_serial; + request->generation = route_generation; + pending_request = request; + } + abort_demand_work.store(true); + if (focus_changed) { + abort_preload_work.store(true); + } + active_request = request->key; + failed_request.reset(); + cv.notify_all(); + } + + void draw(float width, bool loading) { + const float preview_width = std::max(1.0f, width); + const float preview_height = preview_width * preview_aspect(); + drawSized(ImVec2(preview_width, preview_height), loading, false); + ImGui::Spacing(); + } + + void drawSized(ImVec2 size, bool loading, bool fit_to_pane) { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + const float aspect = preview_aspect(); + ImVec2 frame_size = size; + ImVec2 top_left = ImGui::GetCursorScreenPos(); + ImVec2 uv0(0.0f, 0.0f); + ImVec2 uv1(1.0f, 1.0f); + if (aspect > 0.0f && !fit_to_pane) { + frame_size.y = std::min(size.y, size.x * aspect); + frame_size.x = std::min(size.x, frame_size.y / aspect); + top_left = ImVec2(top_left.x + (size.x - frame_size.x) * 0.5f, + top_left.y + (size.y - frame_size.y) * 0.5f); + } else if (aspect > 0.0f && fit_to_pane) { + const float src_aspect = 1.0f / aspect; + const float dst_aspect = size.x / size.y; + if (dst_aspect > src_aspect) { + const float visible_v = std::clamp(src_aspect / dst_aspect, 0.0f, 1.0f); + const float v_pad = (1.0f - visible_v) * 0.5f; + uv0.y = v_pad; + uv1.y = 1.0f - v_pad; + } else if (dst_aspect < src_aspect) { + const float visible_u = std::clamp(dst_aspect / src_aspect, 0.0f, 1.0f); + const float u_pad = (1.0f - visible_u) * 0.5f; + uv0.x = u_pad; + uv1.x = 1.0f - u_pad; + } + } + ImGui::InvisibleButton("##camera_feed_sized", size); + if (texture != 0) { + ImGui::GetWindowDrawList()->AddImage(static_cast(texture), + top_left, + ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), + uv0, + uv1); + } else { + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(top_left, ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), IM_COL32(45, 47, 50, 255)); + draw_list->AddRect(top_left, ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), IM_COL32(95, 100, 106, 255)); + + const char *label = (loading || has_video_source()) ? "loading" : "no video"; + const ImVec2 text_size = ImGui::CalcTextSize(label); + const ImVec2 text_pos(top_left.x + (frame_size.x - text_size.x) * 0.5f, + top_left.y + (frame_size.y - text_size.y) * 0.5f); + draw_list->AddText(text_pos, IM_COL32(187, 187, 187, 255), label); + } + } + + static bool same_request(const std::optional &lhs, const RequestKey &rhs) { + return lhs.has_value() && lhs->segment == rhs.segment && lhs->decode_index == rhs.decode_index; + } + + bool has_video_source() const { + std::lock_guard lock(mutex); + return !route_index.entries.empty() && !route_index.segment_files.empty(); + } + + float preview_aspect() const { + if (frame_width > 0 && frame_height > 0) return static_cast(frame_height) / static_cast(frame_width); + return kDefaultAspect; + } + + std::optional request_for_time(double tracker_time) const { + std::lock_guard lock(mutex); + if (route_index.entries.empty()) return std::nullopt; + + auto it = std::lower_bound(route_index.entries.begin(), route_index.entries.end(), tracker_time, + [](const CameraFrameIndexEntry &entry, double tm) { + return entry.timestamp < tm; + }); + if (it == route_index.entries.end()) { + it = std::prev(route_index.entries.end()); + } else if (it != route_index.entries.begin()) { + const auto prev = std::prev(it); + if (std::abs(prev->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = prev; + } + } + + auto path_it = std::find_if(route_index.segment_files.begin(), route_index.segment_files.end(), + [&](const CameraSegmentFile &segment) { + return segment.segment == it->segment && !segment.path.empty(); + }); + if (path_it == route_index.segment_files.end()) return std::nullopt; + + return DecodeRequest{ + .key = RequestKey{.segment = it->segment, .decode_index = it->decode_index}, + .path = path_it->path, + }; + } + + void upload_pending_result() { + std::optional result; + { + std::lock_guard lock(mutex); + if (!pending_result.has_value()) { + return; + } + result = std::move(pending_result); + pending_result.reset(); + } + + active_request.reset(); + if (!result->success || result->rgba.empty() || result->width <= 0 || result->height <= 0) { + failed_request = result->key; + return; + } + + upload_result(*result); + } + + void upload_result(const DecodeResult &result) { + remember_cached_result(result); + + const bool new_size = texture_width != result.width || texture_height != result.height; + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + if (new_size) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, result.width, result.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, result.rgba.data()); + texture_width = result.width; + texture_height = result.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, result.width, result.height, GL_RGBA, GL_UNSIGNED_BYTE, result.rgba.data()); + } + glBindTexture(GL_TEXTURE_2D, 0); + + frame_width = result.width; + frame_height = result.height; + displayed_request = result.key; + failed_request.reset(); + } + + bool try_upload_cached_result(const RequestKey &key) { + std::optional result; + { + std::lock_guard lock(mutex); + auto it = std::find_if(cached_results.begin(), cached_results.end(), [&](const DecodeResult &cached) { + return cached.key.segment == key.segment && cached.key.decode_index == key.decode_index; + }); + if (it == cached_results.end()) { + return false; + } + result = *it; + } + active_request.reset(); + upload_result(*result); + return true; + } + + bool try_upload_nearby_cached_result(const RequestKey &key) { + std::optional result; + int best_distance = std::numeric_limits::max(); + { + std::lock_guard lock(mutex); + for (const DecodeResult &cached : cached_results) { + if (cached.key.segment != key.segment) continue; + const int distance = std::abs(cached.key.decode_index - key.decode_index); + if (distance == 0 || distance > kImmediateNearbyFrameDistance || distance >= best_distance) continue; + best_distance = distance; + result = cached; + } + } + if (!result.has_value()) { + return false; + } + upload_result(*result); + return true; + } + + void remember_cached_result(const DecodeResult &result) { + std::lock_guard lock(mutex); + auto it = std::find_if(cached_results.begin(), cached_results.end(), [&](const DecodeResult &cached) { + return cached.key.segment == result.key.segment && cached.key.decode_index == result.key.decode_index; + }); + if (it != cached_results.end()) { + cached_results.erase(it); + } + cached_results.push_front(result); + while (cached_results.size() > kCachedFrames) { + cached_results.pop_back(); + } + } + + void destroy_texture() { + if (texture != 0 && g_glfw_alive.load() && glfwGetCurrentContext() != nullptr) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + frame_width = 0; + frame_height = 0; + } + + static bool ensure_decode_buffer(FrameReader *reader, VisionBuf *buf, bool &allocated, int &w, int &h) { + if (!reader) return false; + if (allocated && w == reader->width && h == reader->height) return true; + if (allocated) { buf->free(); allocated = false; } + const auto [stride, y_height, _uv_height, size] = get_nv12_info(reader->width, reader->height); + buf->allocate(size); + buf->init_yuv(reader->width, reader->height, stride, stride * y_height); + w = reader->width; + h = reader->height; + allocated = true; + return true; + } + + void publish_result(const DecodeRequest &request, DecodeResult result) { + std::lock_guard lock(mutex); + if (!pending_request.has_value() || pending_request->serial != request.serial || + pending_request->generation != request.generation) { + return; + } + pending_result = std::move(result); + } + + bool has_newer_pending_request(uint64_t serial) const { + std::lock_guard lock(mutex); + return pending_request.has_value() && pending_request->serial != serial; + } + + void rebuild_preload_queue_locked(int focus_segment) { + std::vector ordered; + ordered.reserve(route_index.segment_files.size()); + for (const CameraSegmentFile &segment_file : route_index.segment_files) { + if (segment_file.path.empty()) continue; + if (segment_file.segment == focus_segment) continue; + ordered.push_back(PreloadTask{ + .segment = segment_file.segment, + .path = segment_file.path, + .generation = route_generation, + }); + } + std::sort(ordered.begin(), ordered.end(), [&](const PreloadTask &a, const PreloadTask &b) { + const int distance_a = std::abs(a.segment - focus_segment); + const int distance_b = std::abs(b.segment - focus_segment); + if (distance_a != distance_b) return distance_a < distance_b; + return a.segment < b.segment; + }); + preload_queue.assign(ordered.begin(), ordered.end()); + preload_focus_segment = focus_segment; + } + + std::shared_ptr find_loaded_reader_locked(int segment, uint64_t generation) { + if (readers_generation != generation) { + readers.clear(); + loading_segments.clear(); + readers_generation = generation; + } + auto it = readers.find(segment); + return it != readers.end() ? it->second : nullptr; + } + + std::shared_ptr ensure_reader_loaded(int segment, + const std::string &path, + uint64_t generation, + const char *reason, + std::atomic *abort_flag, + bool wait_for_inflight) { + while (!stop.load()) { + { + std::unique_lock lock(readers_mutex); + if (std::shared_ptr cached = find_loaded_reader_locked(segment, generation)) { + return cached; + } + if (loading_segments.find(segment) != loading_segments.end()) { + if (!wait_for_inflight) { + return nullptr; + } + readers_cv.wait(lock, [&]() { + return stop.load() + || readers_generation != generation + || loading_segments.find(segment) == loading_segments.end(); + }); + continue; + } + loading_segments.insert(segment); + } + + auto reader = std::make_shared(); + const auto load_begin = std::chrono::steady_clock::now(); + const bool loaded = reader->load(decoder_camera_type(camera_view), path, false, abort_flag, true); + + { + std::lock_guard lock(readers_mutex); + if (readers_generation != generation) { + readers.clear(); + loading_segments.clear(); + readers_generation = generation; + } + loading_segments.erase(segment); + if (loaded) { + readers[segment] = reader; + } + } + readers_cv.notify_all(); + + if (!loaded) { + return nullptr; + } + if (kLogCameraTimings) { + const double load_ms = std::chrono::duration(std::chrono::steady_clock::now() - load_begin).count(); + std::fprintf(stderr, "camera[%s] %s-load seg=%d %.1fms\n", + camera_view_spec(camera_view).runtime_name, reason, segment, load_ms); + } + return reader; + } + return nullptr; + } + + void preload_worker_loop() { + while (true) { + std::optional preload; + { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { return stop.load() || !preload_queue.empty(); }); + if (stop.load()) { + break; + } + preload = preload_queue.front(); + preload_queue.pop_front(); + } + + abort_preload_work.store(false); + { + std::lock_guard lock(readers_mutex); + if (find_loaded_reader_locked(preload->segment, preload->generation)) { + continue; + } + } + ensure_reader_loaded(preload->segment, preload->path, preload->generation, "preload", + &abort_preload_work, false); + } + } + + void demand_worker_loop() { + uint64_t handled_serial = 0; + VisionBuf decode_buffer; + bool buffer_allocated = false; + int buffer_width = 0; + int buffer_height = 0; + + while (true) { + std::optional request; + { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { + return stop.load() || (pending_request.has_value() && pending_request->serial != handled_serial); + }); + if (stop.load()) break; + request = *pending_request; + handled_serial = request->serial; + } + + abort_demand_work.store(false); + + DecodeResult result{.key = request->key}; + std::shared_ptr reader = ensure_reader_loaded(request->key.segment, request->path, + request->generation, "demand", + &abort_demand_work, true); + if (!reader) { + publish_result(*request, std::move(result)); + continue; + } + if (has_newer_pending_request(request->serial)) { + continue; + } + + const auto decode_begin = std::chrono::steady_clock::now(); + if (!ensure_decode_buffer(reader.get(), &decode_buffer, buffer_allocated, buffer_width, buffer_height) || + !reader->get(request->key.decode_index, &decode_buffer)) { + publish_result(*request, std::move(result)); + continue; + } + + result.width = reader->width; + result.height = reader->height; + result.rgba.resize(static_cast(result.width) * static_cast(result.height) * 4U, 0); + libyuv::NV12ToABGR(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + result.rgba.data(), + result.width * 4, + result.width, + result.height); + result.success = true; + result.decode_ms = std::chrono::duration(std::chrono::steady_clock::now() - decode_begin).count(); + publish_result(*request, std::move(result)); + + for (int offset = 1; offset <= kPrefetchAhead; ++offset) { + if (stop.load() || has_newer_pending_request(request->serial)) { + break; + } + const int next_index = request->key.decode_index + offset; + if (next_index < 0 || next_index >= static_cast(reader->getFrameCount())) { + break; + } + if (!reader->get(next_index, &decode_buffer)) { + break; + } + DecodeResult prefetched{ + .key = RequestKey{.segment = request->key.segment, .decode_index = next_index}, + .success = true, + .width = reader->width, + .height = reader->height, + }; + prefetched.rgba.resize(static_cast(prefetched.width) * static_cast(prefetched.height) * 4U, 0); + libyuv::NV12ToABGR(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + prefetched.rgba.data(), + prefetched.width * 4, + prefetched.width, + prefetched.height); + remember_cached_result(prefetched); + } + } + + if (buffer_allocated) { + decode_buffer.free(); + } + } + + mutable std::mutex mutex; + std::condition_variable cv; + std::thread demand_worker; + std::vector preload_workers; + std::atomic stop{false}; + std::atomic abort_demand_work{false}; + std::atomic abort_preload_work{false}; + CameraFeedIndex route_index; + CameraViewKind camera_view = CameraViewKind::Road; + std::optional pending_request; + std::optional pending_result; + std::deque preload_queue; + int preload_focus_segment = -1; + std::deque cached_results; + uint64_t latest_request_serial = 0; + uint64_t route_generation = 1; + std::optional active_request; + std::optional displayed_request; + std::optional failed_request; + std::mutex readers_mutex; + std::condition_variable readers_cv; + std::unordered_map> readers; + std::unordered_set loading_segments; + uint64_t readers_generation = 0; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; + int frame_width = 0; + int frame_height = 0; +}; + +CameraFeedView::CameraFeedView() + : impl_(std::make_unique()) {} + +CameraFeedView::~CameraFeedView() = default; + +void CameraFeedView::setRouteData(const RouteData &route_data) { + impl_->setRouteData(route_data); +} + +void CameraFeedView::setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view) { + impl_->setCameraIndex(camera_index, view); +} + +void CameraFeedView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void CameraFeedView::draw(float width, bool loading) { + impl_->draw(width, loading); +} + +void CameraFeedView::drawSized(ImVec2 size, bool loading, bool fit_to_pane) { + impl_->drawSized(size, loading, fit_to_pane); +} diff --git a/tools/jotpluggler/session.cc b/tools/jotpluggler/session.cc new file mode 100644 index 000000000..22dd7dd46 --- /dev/null +++ b/tools/jotpluggler/session.cc @@ -0,0 +1,773 @@ +#include "tools/jotpluggler/internal.h" + +#include "imgui_internal.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path) { + auto it = session.series_by_path.find(path); + return it == session.series_by_path.end() ? nullptr : it->second; +} + +void sync_camera_feeds(AppSession *session) { + for (size_t i = 0; i < kCameraViewSpecs.size(); ++i) { + if (session->pane_camera_feeds[i]) { + session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); + } + } +} + +void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { + if (!route_data.route_id.empty()) { + session->route_id = route_data.route_id; + } else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) { + session->route_id = {}; + } + session->route_data = std::move(route_data); + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + refresh_all_custom_curves(session, state); + sync_camera_feeds(session); + state->has_shared_range = false; + state->has_tracker_time = false; + reset_shared_range(state, *session); +} + +bool restore_undo_layout(AppSession *session, UiState *state, const SketchLayout &layout, const char *status_text) { + session->layout = layout; + cancel_rename_tab(state); + state->custom_series.request_select = false; + state->active_tab_index = std::clamp(layout.current_tab_index, 0, std::max(0, static_cast(layout.tabs.size()) - 1)); + state->requested_tab_index = state->active_tab_index; + sync_ui_state(state, session->layout); + mark_all_docks_dirty(state); + const bool autosave_ok = autosave_layout(session, state); + if (autosave_ok) { + state->status_text = status_text; + } + return autosave_ok; +} + +bool apply_undo(AppSession *session, UiState *state) { + if (!state->undo.can_undo()) { + return false; + } + return restore_undo_layout(session, state, state->undo.undo(), "Undo"); +} + +bool apply_redo(AppSession *session, UiState *state) { + if (!state->undo.can_redo()) { + return false; + } + return restore_undo_layout(session, state, state->undo.redo(), "Redo"); +} + +std::optional> tab_default_x_range(const WorkspaceTab &tab) { + bool found = false; + double min_value = 0.0; + double max_value = 1.0; + for (const Pane &pane : tab.panes) { + if (!pane.range.valid || pane.range.right <= pane.range.left) continue; + if (!found) { + min_value = pane.range.left; + max_value = pane.range.right; + found = true; + } else { + min_value = std::min(min_value, pane.range.left); + max_value = std::max(max_value, pane.range.right); + } + } + if (!found) return std::nullopt; + return std::make_pair(min_value, max_value); +} + +bool infer_stream_follow_state(const UiState &state, const AppSession &session) { + if (session.data_mode != SessionDataMode::Stream || !state.has_shared_range || !session.route_data.has_time_range) { + return false; + } + const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds); + const double current_span = std::max(0.0, state.x_view_max - state.x_view_min); + const double edge_epsilon = std::max(0.05, target_span * 0.02); + return std::abs(state.x_view_max - state.route_x_max) <= edge_epsilon + && std::abs(current_span - target_span) <= edge_epsilon; +} + +void ensure_shared_range(UiState *state, const AppSession &session) { + if (session.route_data.has_time_range) { + state->route_x_min = session.route_data.x_min; + state->route_x_max = session.route_data.x_max; + } else { + state->route_x_min = 0.0; + state->route_x_max = 1.0; + } + + if (state->has_shared_range) { + return; + } + + if (session.data_mode == SessionDataMode::Stream) { + const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds); + if (session.route_data.has_time_range) { + state->x_view_max = state->route_x_max; + state->x_view_min = state->x_view_max - target_span; + } else { + state->x_view_min = 0.0; + state->x_view_max = target_span; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + 1.0; + } + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_max; + state->has_tracker_time = session.route_data.has_time_range; + } + return; + } + + if (const WorkspaceTab *tab = app_active_tab(session.layout, *state); tab != nullptr) { + if (std::optional> tab_range = tab_default_x_range(*tab); tab_range.has_value()) { + state->x_view_min = tab_range->first; + state->x_view_max = tab_range->second; + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_min; + state->has_tracker_time = true; + } + return; + } + } + + state->x_view_min = state->route_x_min; + state->x_view_max = state->route_x_max; + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + 1.0; + } + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_min; + state->has_tracker_time = true; + } +} + +void clamp_shared_range(UiState *state, const AppSession &session) { + if (!state->has_shared_range) { + return; + } + const double min_span = MIN_HORIZONTAL_ZOOM_SECONDS; + double span = state->x_view_max - state->x_view_min; + if (span < min_span) { + const double center = 0.5 * (state->x_view_min + state->x_view_max); + span = min_span; + state->x_view_min = center - span * 0.5; + state->x_view_max = center + span * 0.5; + } + if (session.data_mode == SessionDataMode::Stream) { + if (session.route_data.has_time_range && state->x_view_max > state->route_x_max) { + state->x_view_min -= state->x_view_max - state->route_x_max; + state->x_view_max = state->route_x_max; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + min_span; + } + if (state->has_tracker_time && session.route_data.has_time_range) { + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); + } + if (session.route_data.has_time_range) { + state->follow_latest = infer_stream_follow_state(*state, session); + } + return; + } + if (state->route_x_max > state->route_x_min) { + if (state->x_view_min < state->route_x_min) { + state->x_view_max += state->route_x_min - state->x_view_min; + state->x_view_min = state->route_x_min; + } + if (state->x_view_max > state->route_x_max) { + state->x_view_min -= state->x_view_max - state->route_x_max; + state->x_view_max = state->route_x_max; + } + if (state->x_view_min < state->route_x_min) { + state->x_view_min = state->route_x_min; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = std::min(state->route_x_max, state->x_view_min + min_span); + } + } + if (state->has_tracker_time) { + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); + } +} + +void reset_shared_range(UiState *state, const AppSession &session) { + state->has_shared_range = false; + ensure_shared_range(state, session); + clamp_shared_range(state, session); +} + +void update_follow_range(UiState *state, const AppSession &session) { + if (!state->follow_latest || !state->has_shared_range) { + return; + } + const double span = session.data_mode == SessionDataMode::Stream + ? std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds) + : std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min); + const double route_span = state->route_x_max - state->route_x_min; + if (route_span <= 0.0) { + return; + } + state->x_view_max = state->route_x_max; + state->x_view_min = state->x_view_max - span; + clamp_shared_range(state, session); +} + +void advance_playback(UiState *state, const AppSession &session) { + if (!state->playback_playing || !state->has_shared_range || state->route_x_max <= state->route_x_min) { + return; + } + + const double delta = std::max(0.0, static_cast(ImGui::GetIO().DeltaTime)) * state->playback_rate; + const double view_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min); + const double loop_min = state->playback_loop + ? std::clamp(state->x_view_min, state->route_x_min, state->route_x_max) + : state->route_x_min; + const double loop_max = state->playback_loop + ? std::clamp(state->x_view_max, state->route_x_min, state->route_x_max) + : state->route_x_max; + + state->tracker_time += delta; + if (state->tracker_time >= loop_max) { + if (state->playback_loop) { + state->tracker_time = loop_min; + } else { + state->tracker_time = state->route_x_max; + state->playback_playing = false; + } + } + + if (!state->playback_loop) { + constexpr double kScrollStartFraction = 0.70; + const double scroll_anchor = state->x_view_min + view_span * kScrollStartFraction; + if (state->tracker_time > scroll_anchor && state->x_view_max < state->route_x_max) { + state->x_view_min = state->tracker_time - view_span * kScrollStartFraction; + state->x_view_max = state->x_view_min + view_span; + clamp_shared_range(state, session); + } else if (state->tracker_time < state->x_view_min || state->tracker_time > state->x_view_max) { + state->x_view_min = state->tracker_time - view_span * 0.5; + state->x_view_max = state->x_view_min + view_span; + clamp_shared_range(state, session); + } + } +} + +void step_tracker(UiState *state, double direction) { + if (!state->has_shared_range) { + return; + } + state->tracker_time += direction * std::max(0.001, state->playback_step); + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); +} + +const char *log_selector_name(LogSelector selector) { + static constexpr const char *kLabels[] = {"a", "r", "q"}; + const size_t index = static_cast(selector); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +const char *log_selector_description(LogSelector selector) { + static constexpr const char *kLabels[] = { + "any of rlog or qlog", + "rlog only", + "qlog only", + }; + const size_t index = static_cast(selector); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +std::string shorten_route_part(std::string_view text, size_t keep) { + if (text.size() <= keep) { + return std::string(text); + } + return std::string(text.substr(0, keep)); +} + +bool parse_slice_spec(std::string_view text, int *begin, int *end) { + const auto parse_nonnegative = [](std::string_view value, int *out) { + if (value.empty()) return false; + char *end_ptr = nullptr; + const long parsed = std::strtol(std::string(value).c_str(), &end_ptr, 10); + if (end_ptr == nullptr || *end_ptr != '\0' || parsed < 0) { + return false; + } + *out = static_cast(parsed); + return true; + }; + const std::string trimmed = util::strip(std::string(text)); + if (trimmed.empty()) { + return false; + } + const size_t colon = trimmed.find(':'); + int parsed_begin = 0; + if (!parse_nonnegative(trimmed.substr(0, colon), &parsed_begin)) { + return false; + } + int parsed_end = parsed_begin; + if (colon != std::string::npos) { + const std::string end_text = trimmed.substr(colon + 1); + if (end_text.empty()) { + parsed_end = -1; + } else if (!parse_nonnegative(end_text, &parsed_end) || parsed_end < parsed_begin) { + return false; + } + } + *begin = parsed_begin; + *end = parsed_end; + return true; +} + +std::string format_duration_short(double seconds) { + const double clamped = std::max(0.0, seconds); + const int total_ms = static_cast(std::round(clamped * 1000.0)); + const int minutes = total_ms / 60000; + const int rem_ms = total_ms % 60000; + const int secs = rem_ms / 1000; + const int millis = rem_ms % 1000; + return util::string_format("%d:%02d.%03d", minutes, secs, millis); +} + +bool apply_route_identifier(AppSession *session, UiState *state, const RouteIdentifier &route_id, const char *status_text) { + if (route_id.empty()) { + return false; + } + if (!reload_session(session, state, route_id.full_spec(), session->data_dir)) { + return false; + } + state->status_text = status_text; + return true; +} + +bool apply_route_slice_change(AppSession *session, UiState *state, std::string_view slice_text) { + int begin = 0; + int end = 0; + if (!parse_slice_spec(slice_text, &begin, &end)) { + state->error_text = "Slice must be N or N:M."; + state->open_error_popup = true; + return false; + } + RouteIdentifier next = session->route_id; + next.slice_begin = begin; + next.slice_end = end; + next.slice_explicit = true; + return apply_route_identifier(session, state, next, "Updated route slice"); +} + +bool apply_route_selector_change(AppSession *session, UiState *state, LogSelector selector) { + RouteIdentifier next = session->route_id; + next.selector = selector; + next.selector_explicit = true; + return apply_route_identifier(session, state, next, "Updated log selector"); +} + +ImU32 route_chip_part_color(int part_index, bool explicit_part) { + constexpr std::array, 4> BASE = {{ + {70, 96, 126}, // dongle + {100, 86, 148}, // log id + {72, 112, 86}, // slice + {156, 104, 38}, // selector + }}; + const std::array &base = BASE[static_cast(std::clamp(part_index, 0, 3))]; + if (explicit_part) { + return ImGui::GetColorU32(color_rgb(base[0], base[1], base[2])); + } + const int gray = 144; + return ImGui::GetColorU32(color_rgb((base[0] + gray) / 2, (base[1] + gray) / 2, (base[2] + gray) / 2)); +} + +bool draw_route_chip_text_button(const char *id, + std::string_view text, + ImVec2 pos, + ImU32 color, + ImDrawList *draw_list, + const char *tooltip = nullptr) { + const ImVec2 size = ImGui::CalcTextSize(text.data(), text.data() + text.size()); + ImGui::SetCursorScreenPos(pos); + ImGui::InvisibleButton(id, size); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + draw_list->AddRectFilled(ImVec2(pos.x - 5.0f, pos.y - 1.0f), + ImVec2(pos.x + size.x + 5.0f, pos.y + size.y + 2.0f), + ImGui::GetColorU32(color_rgb(225, 231, 239, 0.95f)), 0.0f); + } + draw_list->AddText(pos, color, text.data(), text.data() + text.size()); + if (tooltip != nullptr && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(tooltip); + ImGui::EndTooltip(); + } + return ImGui::IsItemClicked(ImGuiMouseButton_Left); +} + +void draw_route_copy_feedback(UiState *state, ImDrawList *draw_list, ImVec2 chip_max) { + if (state->route_copy_feedback_text.empty()) { + return; + } + const double now = ImGui::GetTime(); + if (now >= state->route_copy_feedback_until) { + state->route_copy_feedback_text.clear(); + state->route_copy_feedback_until = 0.0; + return; + } + + const float alpha = static_cast(std::clamp((state->route_copy_feedback_until - now) / 1.1, 0.0, 1.0)); + const ImVec2 text_size = ImGui::CalcTextSize(state->route_copy_feedback_text.c_str()); + const ImVec2 pad(9.0f, 5.0f); + const ImVec2 bubble_min(chip_max.x - text_size.x - pad.x * 2.0f, chip_max.y + 7.0f); + const ImVec2 bubble_max(chip_max.x, bubble_min.y + text_size.y + pad.y * 2.0f); + draw_list->AddRectFilled(bubble_min, bubble_max, + ImGui::GetColorU32(color_rgb(46, 125, 80, 0.96f * alpha)), 7.0f); + draw_list->AddRect(bubble_min, bubble_max, + ImGui::GetColorU32(color_rgb(35, 96, 61, 0.9f * alpha)), 7.0f, 0, 1.0f); + draw_list->AddText(ImVec2(std::floor(bubble_min.x + pad.x), std::floor(bubble_min.y + pad.y)), + ImGui::GetColorU32(color_rgb(247, 251, 248, alpha)), + state->route_copy_feedback_text.c_str()); +} + +void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { + if (session->route_id.empty()) { + return; + } + ImGui::SetNextWindowPos(anchor, ImGuiCond_Appearing); + ImGui::SetNextWindowSizeConstraints(ImVec2(300.0f, 0.0f), ImVec2(420.0f, FLT_MAX)); + if (!ImGui::BeginPopup("##route_info_popup", + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings)) { + return; + } + + ImGui::TextUnformatted("Route Info"); + ImGui::Separator(); + app_push_mono_font(); + ImGui::TextUnformatted(session->route_id.canonical().c_str()); + app_pop_mono_font(); + + const char *copy_icon = icon::CLIPBOARD; + const char *link_icon = icon::BOX_ARROW_UP_RIGHT; + const std::string useradmin_label = std::string("Useradmin ") + link_icon; + const std::string connect_label = std::string("comma connect ") + link_icon; + if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) { + ImGui::SetClipboardText(session->route_id.canonical().c_str()); + state->status_text = "Copied route to clipboard"; + state->route_copy_feedback_text = "Copied"; + state->route_copy_feedback_until = ImGui::GetTime() + 1.1; + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted("Copy route"); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) { + open_external_url(route_useradmin_url(session->route_id)); + state->status_text = "Opened useradmin"; + } + ImGui::SameLine(); + if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) { + open_external_url(route_connect_url(session->route_id)); + state->status_text = "Opened comma connect"; + } + + ImGui::Spacing(); + const int loaded_begin = session->route_id.available_begin; + const int loaded_end = session->route_id.available_end; + const int loaded_count = loaded_end >= loaded_begin ? (loaded_end - loaded_begin + 1) : 0; + ImGui::Text("Duration %s", format_duration_short(session->route_data.x_max - session->route_data.x_min).c_str()); + ImGui::Text("Segments %s (%d)", session->route_id.display_slice().c_str(), loaded_count); + ImGui::Text("Selector %s", log_selector_description(session->route_id.selector)); + if (!session->route_data.car_fingerprint.empty()) { + ImGui::TextWrapped("Car %s", session->route_data.car_fingerprint.c_str()); + } + if (!session->route_data.dbc_name.empty()) { + ImGui::TextWrapped("DBC %s", session->route_data.dbc_name.c_str()); + } + + ImGui::EndPopup(); +} + +void draw_route_id_chip(AppSession *session, UiState *state) { + if (session->data_mode != SessionDataMode::Route || session->route_id.empty()) { + return; + } + + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const RouteIdentifier &route_id = session->route_id; + app_push_bold_font(); + const std::string dongle_text = shorten_route_part(route_id.dongle_id, 8); + const std::string log_text = shorten_route_part(route_id.log_id, 16); + const std::string slice_text = route_id.display_slice(); + const std::string selector_text(1, route_id.selector_char()); + const std::string sep_text = " / "; + + const ImVec2 dongle_size = ImGui::CalcTextSize(dongle_text.c_str()); + const ImVec2 log_size = ImGui::CalcTextSize(log_text.c_str()); + const ImVec2 slice_size = state->editing_route_slice + ? ImVec2(68.0f, ImGui::GetFrameHeight()) + : ImGui::CalcTextSize(slice_text.c_str()); + const ImVec2 selector_size = ImGui::CalcTextSize(selector_text.c_str()); + const ImVec2 sep_size = ImGui::CalcTextSize(sep_text.c_str()); + constexpr float chip_pad_x = 12.0f; + constexpr float info_size = 18.0f; + const float chip_h = 28.0f; + const float chip_w = chip_pad_x * 2.0f + dongle_size.x + sep_size.x + log_size.x + sep_size.x + + slice_size.x + sep_size.x + selector_size.x + 10.0f + info_size; + const float menu_right = window->Pos.x + window->Size.x - 8.0f; + const float cursor_x = ImGui::GetCursorScreenPos().x + 4.0f; + const float chip_x = std::clamp(cursor_x, window->Pos.x + 48.0f, std::max(window->Pos.x + 48.0f, menu_right - chip_w)); + const float chip_y = std::floor(window->Pos.y + std::max(0.0f, (window->Size.y - chip_h) * 0.5f)); + const ImVec2 chip_min(chip_x, chip_y); + const ImVec2 chip_max(chip_x + chip_w, chip_y + chip_h); + const float text_y = std::floor(chip_y + std::max(0.0f, (chip_h - ImGui::GetTextLineHeight()) * 0.5f)); + const ImU32 chip_bg = ImGui::GetColorU32(color_rgb(247, 249, 252)); + const ImU32 chip_border = ImGui::GetColorU32(color_rgb(184, 191, 200)); + const ImU32 sep = ImGui::GetColorU32(color_rgb(162, 170, 178)); + draw_list->AddRectFilled(chip_min, chip_max, chip_bg, 0.0f); + draw_list->AddRect(chip_min, chip_max, chip_border, 0.0f, 0, 1.0f); + + float x = chip_x + chip_pad_x; + const bool dongle_click = draw_route_chip_text_button( + "##route_dongle", dongle_text, ImVec2(x, text_y), route_chip_part_color(0, true), draw_list, + "Device identifier"); + x += dongle_size.x; + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + const bool log_click = draw_route_chip_text_button( + "##route_log", log_text, ImVec2(x, text_y), route_chip_part_color(1, true), draw_list, + "Route identifier"); + x += log_size.x; + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + + if (state->editing_route_slice) { + ImGui::SetCursorScreenPos(ImVec2(x - 4.0f, chip_y + 1.0f)); + ImGui::SetNextItemWidth(76.0f); + if (state->focus_route_slice_input) { + ImGui::SetKeyboardFocusHere(); + state->focus_route_slice_input = false; + } + const bool applied = input_text_string("##route_slice_edit", &state->route_slice_buffer, + ImGuiInputTextFlags_EnterReturnsTrue); + const bool deactivated = ImGui::IsItemDeactivated(); + const bool clicked_elsewhere = ImGui::IsMouseClicked(ImGuiMouseButton_Left) + && !ImGui::IsItemHovered() + && !ImGui::IsItemActive(); + if (applied) { + if (apply_route_slice_change(session, state, state->route_slice_buffer)) { + state->editing_route_slice = false; + } + } else if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + state->editing_route_slice = false; + } else if (deactivated || clicked_elsewhere) { + const std::string trimmed = util::strip(state->route_slice_buffer); + if (trimmed != route_id.display_slice()) { + int begin = 0; + int end = 0; + if (parse_slice_spec(trimmed, &begin, &end)) { + apply_route_slice_change(session, state, trimmed); + } else { + state->status_text = "Canceled route slice edit"; + } + } + state->editing_route_slice = false; + } + x += slice_size.x; + } else { + const bool slice_click = draw_route_chip_text_button( + "##route_slice", slice_text, ImVec2(x, text_y), + route_chip_part_color(2, route_id.slice_explicit), draw_list, + "Segment range"); + if (slice_click) { + state->editing_route_slice = true; + state->focus_route_slice_input = true; + state->route_slice_buffer = route_id.display_slice(); + } + x += slice_size.x; + } + + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + const bool selector_click = draw_route_chip_text_button( + "##route_selector", selector_text, ImVec2(x, text_y), + route_chip_part_color(3, route_id.selector_explicit), draw_list, + "Log selector"); + if (selector_click) { + ImGui::OpenPopup("##route_selector_popup"); + } + x += selector_size.x + 10.0f; + + const ImVec2 info_center(x + info_size * 0.5f, chip_y + chip_h * 0.5f); + ImGui::SetCursorScreenPos(ImVec2(x, chip_y + (chip_h - info_size) * 0.5f)); + ImGui::InvisibleButton("##route_info_button", ImVec2(info_size, info_size)); + const bool info_hovered = ImGui::IsItemHovered(); + if (info_hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + draw_list->AddCircleFilled(info_center, info_size * 0.5f, + ImGui::GetColorU32(info_hovered ? color_rgb(220, 229, 240) : color_rgb(239, 243, 248))); + draw_list->AddCircle(info_center, info_size * 0.5f, chip_border, 20, 1.0f); + const char *info_text = icon::INFO_CIRCLE; + const ImVec2 info_text_size = ImGui::CalcTextSize(info_text); + draw_list->AddText(ImVec2(std::floor(info_center.x - info_text_size.x * 0.5f), + std::floor(info_center.y - info_text_size.y * 0.5f)), + route_chip_part_color(0, true), info_text); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted("Route details"); + ImGui::EndTooltip(); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + ImGui::OpenPopup("##route_info_popup"); + } + + app_pop_bold_font(); + + if (dongle_click || log_click) { + ImGui::SetClipboardText(route_id.canonical().c_str()); + state->status_text = "Copied route to clipboard"; + state->route_copy_feedback_text = "Copied"; + state->route_copy_feedback_until = ImGui::GetTime() + 1.1; + } + + ImGui::SetNextWindowPos(ImVec2(chip_max.x - 60.0f, chip_max.y + 4.0f), ImGuiCond_Appearing); + if (ImGui::BeginPopup("##route_selector_popup")) { + for (LogSelector selector : {LogSelector::Auto, LogSelector::RLog, LogSelector::QLog}) { + const bool selected = route_id.selector == selector; + const std::string label = std::string(log_selector_name(selector)) + " " + log_selector_description(selector); + if (ImGui::Selectable(label.c_str(), selected) && !selected) { + apply_route_selector_change(session, state, selector); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndPopup(); + } + + draw_route_copy_feedback(state, draw_list, chip_max); + draw_route_info_popup(session, state, ImVec2(std::max(window->Pos.x + 16.0f, chip_max.x - 360.0f), chip_max.y + 6.0f)); +} + +std::string format_cache_bytes(uint64_t bytes) { + if (bytes >= (1ULL << 30)) { + return util::string_format("%.1f GiB", static_cast(bytes) / static_cast(1ULL << 30)); + } else if (bytes >= (1ULL << 20)) { + return util::string_format("%.1f MiB", static_cast(bytes) / static_cast(1ULL << 20)); + } else if (bytes >= (1ULL << 10)) { + return util::string_format("%.1f KiB", static_cast(bytes) / static_cast(1ULL << 10)); + } + return util::string_format("%llu B", static_cast(bytes)); +} + +MapCacheStats directory_cache_stats(const fs::path &root) { + MapCacheStats stats; + std::error_code ec; + if (!fs::exists(root, ec)) { + return stats; + } + fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec); + for (const fs::directory_entry &entry : it) { + if (ec) { + ec.clear(); + continue; + } + const fs::file_status status = entry.symlink_status(ec); + if (ec || !fs::is_regular_file(status)) { + ec.clear(); + continue; + } + const uintmax_t size = entry.file_size(ec); + if (!ec) { + stats.bytes += static_cast(size); + ++stats.files; + } else { + ec.clear(); + } + } + return stats; +} + +float draw_main_menu_bar(AppSession *session, UiState *state) { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(7.0f, 5.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(9.0f, 6.0f)); + float height = ImGui::GetFrameHeight(); + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, state->undo.can_undo())) { + apply_undo(session, state); + } + if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, state->undo.can_redo())) { + apply_redo(session, state); + } + ImGui::Separator(); + if (ImGui::MenuItem("Open Route...")) { + state->open_open_route = true; + } + if (ImGui::MenuItem("Stream...")) { + state->open_stream = true; + } + if (ImGui::MenuItem("Find Signal...", "Ctrl+F")) { + state->open_find_signal = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("New Layout")) { + start_new_layout(session, state); + } + if (ImGui::MenuItem("Load Layout...")) { + state->open_load_layout = true; + } + if (ImGui::MenuItem("Save Layout")) { + state->request_save_layout = true; + } + if (ImGui::MenuItem("Save Layout As...")) { + state->open_save_layout = true; + } + if (ImGui::MenuItem("Reset Layout")) { + state->request_reset_layout = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Show DEPRECATED Fields", nullptr, state->show_deprecated_fields)) { + state->show_deprecated_fields = !state->show_deprecated_fields; + rebuild_browser_nodes(session, state); + } + if (ImGui::MenuItem("Show FPS", nullptr, state->show_fps_overlay)) { + state->show_fps_overlay = !state->show_fps_overlay; + } + if (ImGui::MenuItem("Preferences...")) { + state->open_preferences = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Reset Plot View")) { + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + clamp_shared_range(state, *session); + state->suppress_range_side_effects = true; + state->status_text = "Plot view reset"; + } + ImGui::Separator(); + if (ImGui::MenuItem("Close")) { + state->request_close = true; + } + ImGui::EndMenu(); + } + ImGui::SameLine(0.0f, 8.0f); + draw_route_id_chip(session, state); + height = ImGui::GetWindowSize().y; + ImGui::EndMainMenuBar(); + } + ImGui::PopStyleVar(2); + return height; +} diff --git a/tools/jotpluggler/sidebar.cc b/tools/jotpluggler/sidebar.cc new file mode 100644 index 000000000..c120b4790 --- /dev/null +++ b/tools/jotpluggler/sidebar.cc @@ -0,0 +1,215 @@ +#include "tools/jotpluggler/internal.h" + +std::string dbc_combo_label(const AppSession &session) { + if (!session.dbc_override.empty()) return session.dbc_override; + if (!session.route_data.dbc_name.empty()) return "Auto: " + session.route_data.dbc_name; + return "Auto"; +} + +float timeline_time_to_x(double time_value, double route_min, double route_max, float x_min, float x_max) { + const double span = route_max - route_min; + if (span <= 0.0) { + return x_min; + } + const double ratio = (time_value - route_min) / span; + return x_min + static_cast(ratio * static_cast(x_max - x_min)); +} + +double timeline_x_to_time(float x, double route_min, double route_max, float x_min, float x_max) { + const float width = std::max(1.0f, x_max - x_min); + const float clamped_x = std::clamp(x, x_min, x_max); + const double ratio = static_cast((clamped_x - x_min) / width); + return route_min + ratio * (route_max - route_min); +} + +void reset_timeline_view(UiState *state, const AppSession &session) { + state->follow_latest = session.data_mode == SessionDataMode::Stream; + reset_shared_range(state, session); +} + +void draw_timeline_bar_contents(const AppSession &session, UiState *state, float width) { + if (!session.route_data.has_time_range) { + ImGui::Dummy(ImVec2(width, TIMELINE_BAR_HEIGHT)); + return; + } + + const ImVec2 cursor = ImGui::GetCursorScreenPos(); + const ImVec2 size(width, TIMELINE_BAR_HEIGHT); + const ImVec2 bar_min(cursor.x + 1.0f, cursor.y + 1.0f); + const ImVec2 bar_max(cursor.x + size.x - 1.0f, cursor.y + size.y - 1.0f); + const double route_min = state->route_x_min; + const double route_max = state->route_x_max; + const float vp_left = timeline_time_to_x(std::clamp(state->x_view_min, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + const float vp_right = timeline_time_to_x(std::clamp(state->x_view_max, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + + ImGui::InvisibleButton("##timeline_button", size); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool double_clicked = hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + + draw_list->AddRectFilled(bar_min, bar_max, timeline_entry_color(TimelineEntry::Type::None, 0.2f)); + if (session.route_data.timeline.empty()) { + draw_list->AddRectFilled(ImVec2(vp_left, bar_min.y), ImVec2(vp_right, bar_max.y), + timeline_entry_color(TimelineEntry::Type::None, 1.0f)); + } else { + for (const TimelineEntry &entry : session.route_data.timeline) { + float x0 = timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x); + float x1 = timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x); + x1 = std::max(x1, x0 + 1.0f); + draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y), + timeline_entry_color(entry.type, 0.25f)); + } + for (const TimelineEntry &entry : session.route_data.timeline) { + float x0 = std::max(timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x), vp_left); + float x1 = std::min(std::max(timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x), x0 + 1.0f), vp_right); + if (x1 <= x0) { + continue; + } + draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y), + timeline_entry_color(entry.type, 1.0f)); + } + } + + draw_list->AddLine(ImVec2(vp_left, bar_min.y), ImVec2(vp_left, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f); + draw_list->AddLine(ImVec2(vp_right, bar_min.y), ImVec2(vp_right, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f); + if (state->has_tracker_time) { + const float cx = timeline_time_to_x(std::clamp(state->tracker_time, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + draw_list->AddLine(ImVec2(cx, bar_min.y), ImVec2(cx, bar_max.y), IM_COL32(220, 60, 50, 255), 1.5f); + } + draw_list->AddRect(bar_min, bar_max, IM_COL32(170, 178, 186, 255), 0.0f, 0, 1.0f); + + const float edge_grab = 4.0f; + const float mouse_x = ImGui::GetIO().MousePos.x; + const double mouse_time = timeline_x_to_time(mouse_x, route_min, route_max, bar_min.x, bar_max.x); + if (double_clicked) { + reset_timeline_view(state, session); + } else if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + state->timeline_drag_anchor_time = mouse_time; + state->timeline_drag_anchor_x_min = state->x_view_min; + state->timeline_drag_anchor_x_max = state->x_view_max; + if (std::abs(mouse_x - vp_left) <= edge_grab) { + state->timeline_drag_mode = TimelineDragMode::ResizeLeft; + } else if (std::abs(mouse_x - vp_right) <= edge_grab) { + state->timeline_drag_mode = TimelineDragMode::ResizeRight; + } else if (mouse_x >= vp_left && mouse_x <= vp_right) { + state->timeline_drag_mode = TimelineDragMode::PanViewport; + } else { + state->timeline_drag_mode = TimelineDragMode::ScrubCursor; + state->tracker_time = std::clamp(mouse_time, route_min, route_max); + state->has_tracker_time = true; + } + } + + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + state->timeline_drag_mode = TimelineDragMode::None; + } else if (active || state->timeline_drag_mode != TimelineDragMode::None) { + switch (state->timeline_drag_mode) { + case TimelineDragMode::ScrubCursor: + state->tracker_time = std::clamp(mouse_time, route_min, route_max); + state->has_tracker_time = true; + break; + case TimelineDragMode::PanViewport: { + const double delta = mouse_time - state->timeline_drag_anchor_time; + state->x_view_min = state->timeline_drag_anchor_x_min + delta; + state->x_view_max = state->timeline_drag_anchor_x_max + delta; + clamp_shared_range(state, session); + break; + } + case TimelineDragMode::ResizeLeft: + state->x_view_min = std::min(mouse_time, state->x_view_max - MIN_HORIZONTAL_ZOOM_SECONDS); + clamp_shared_range(state, session); + break; + case TimelineDragMode::ResizeRight: + state->x_view_max = std::max(mouse_time, state->x_view_min + MIN_HORIZONTAL_ZOOM_SECONDS); + clamp_shared_range(state, session); + break; + case TimelineDragMode::None: + break; + } + } + + if (hovered) { + if (std::abs(mouse_x - vp_left) <= edge_grab || std::abs(mouse_x - vp_right) <= edge_grab) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } else if (mouse_x >= vp_left && mouse_x <= vp_right) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + ImGui::BeginTooltip(); + ImGui::Text("t=%.1fs — %s", mouse_time, timeline_entry_label(timeline_type_at_time(session.route_data.timeline, mouse_time))); + ImGui::EndTooltip(); + } +} + +void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state) { + ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.status_bar_y)); + ImGui::SetNextWindowSize(ImVec2(ui.content_w, STATUS_BAR_HEIGHT)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(247, 248, 250)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(188, 193, 199)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings; + if (ImGui::Begin("##status_bar", nullptr, flags)) { + draw_timeline_bar_contents(session, state, ui.content_w); + const float row_y = TIMELINE_BAR_HEIGHT + 8.0f; + ImGui::SetCursorPos(ImVec2(8.0f, row_y)); + ImGui::BeginDisabled(!session.route_data.has_time_range); + ImGui::Checkbox("Loop", &state->playback_loop); + ImGui::SameLine(0.0f, 10.0f); + if (ImGui::Button(state->playback_playing ? "Pause" : "Play", ImVec2(56.0f, 0.0f))) { + state->playback_playing = !state->playback_playing; + } + ImGui::SameLine(0.0f, 10.0f); + if (ImGui::Button("Reset View", ImVec2(86.0f, 0.0f))) { + reset_timeline_view(state, session); + } + const float controls_end_x = ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x; + ImGui::EndDisabled(); + + const char *status_text = state->status_text.empty() ? "Ready" : state->status_text.c_str(); + const float status_x = controls_end_x + 16.0f; + ImGui::SetCursorPos(ImVec2(status_x, row_y + 2.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(102, 110, 118)); + ImGui::TextUnformatted(status_text); + ImGui::PopStyleColor(); + + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void draw_sidebar_resizer(const UiMetrics &ui, UiState *state) { + constexpr float kHandleWidth = 14.0f; + ImGui::SetNextWindowPos(ImVec2(ui.sidebar_width - kHandleWidth * 0.5f, ui.top_offset)); + ImGui::SetNextWindowSize(ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset))); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBackground; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + if (ImGui::Begin("##sidebar_resizer", nullptr, flags)) { + ImGui::InvisibleButton("##sidebar_resizer_button", ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset))); + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + if (ImGui::IsItemActive()) { + const float max_width = std::min(SIDEBAR_MAX_WIDTH, ui.width * 0.6f); + state->sidebar_width = std::clamp(ImGui::GetIO().MousePos.x, SIDEBAR_MIN_WIDTH, max_width); + } + + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const ImVec2 origin = ImGui::GetWindowPos(); + draw_list->AddLine(ImVec2(origin.x + kHandleWidth * 0.5f, origin.y), + ImVec2(origin.x + kHandleWidth * 0.5f, origin.y + std::max(1.0f, ui.height - ui.top_offset)), + IM_COL32(194, 198, 204, 255)); + } + ImGui::End(); + ImGui::PopStyleVar(); +} diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc new file mode 100644 index 000000000..cd0bf5101 --- /dev/null +++ b/tools/jotpluggler/sketch_layout.cc @@ -0,0 +1,2202 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/car_fingerprint_to_dbc.h" +#include "tools/jotpluggler/common.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/util.h" +#include "third_party/json11/json11.hpp" +#include "tools/replay/logreader.h" +#include "tools/replay/py_downloader.h" + +namespace fs = std::filesystem; + +namespace { + +struct RouteSelection { + std::string dongle_id; + std::string timestamp; + int begin_segment = 0; + int end_segment = -1; + bool slice_explicit = false; + LogSelector selector = LogSelector::Auto; + bool selector_explicit = false; + std::string canonical_name; +}; + +struct SegmentLogs { + std::string rlog; + std::string qlog; + std::string fcamera; + std::string dcamera; + std::string ecamera; + std::string qcamera; +}; + +enum class ScalarKind { + None, + Bool, + Int, + UInt, + Float, + Enum, +}; + +enum class ResolvedNodeKind { + Ignore, + Scalar, + Struct, + List, +}; + +struct ResolvedNode { + ResolvedNodeKind kind = ResolvedNodeKind::Ignore; + ScalarKind scalar_kind = ScalarKind::None; + int fixed_slot = -1; + bool has_field = false; + capnp::StructSchema::Field field; + std::string segment; + std::string path; + bool skip_large_scalar_list = false; + std::vector children; + std::unique_ptr element; +}; + +struct ResolvedService { + uint16_t event_which = 0; + capnp::StructSchema::Field union_field; + std::string service_name; + int valid_slot = -1; + int log_mono_time_slot = -1; + int seconds_slot = -1; + ResolvedNode payload; +}; + +struct SchemaIndex { + std::vector> by_which; + size_t fixed_series_count = 0; + std::vector fixed_paths; + + static const SchemaIndex &instance(); +}; + +constexpr size_t INVALID_DYNAMIC_SLOT = std::numeric_limits::max(); + +struct SeriesAccumulator { + explicit SeriesAccumulator(size_t fixed_count = 0) : fixed_series(fixed_count) {} + + std::vector fixed_series; + std::vector dynamic_series; + std::vector can_messages; + std::unordered_map dynamic_slots; + std::unordered_map> list_scalar_slots; + std::unordered_map can_message_slots; + std::unordered_map enum_info; +}; + +struct LoadedRouteArtifacts { + std::vector series; + std::vector can_messages; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; +}; + +struct RouteMetadata { + std::string car_fingerprint; +}; + +struct LoadStats { + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + + struct SegmentStats { + int segment_number = -1; + std::string log_path; + double download_seconds = 0.0; + double decompress_seconds = 0.0; + double parse_seconds = 0.0; + double extract_seconds = 0.0; + size_t compressed_bytes = 0; + size_t decompressed_bytes = 0; + size_t event_count = 0; + size_t series_count = 0; + bool failed = false; + }; + + explicit LoadStats(const RouteLoadProgressCallback &callback) : progress(callback) {} + + void publish(RouteLoadStage stage, size_t segment_index, const std::string &segment_name) { + if (!progress) { + return; + } + RouteLoadProgress update; + update.stage = stage; + update.segment_index = segment_index; + update.segment_count = segment_count; + update.current = stage == RouteLoadStage::DownloadingSegment + ? segments_downloaded.load() + : segments_parsed.load(); + update.total = total_segments.load(); + update.segments_downloaded = segments_downloaded.load(); + update.segments_parsed = segments_parsed.load(); + update.total_segments = total_segments.load(); + update.bytes_downloaded = bytes_downloaded.load(); + update.num_workers = num_workers; + update.segment_name = segment_name; + std::lock_guard lock(progress_mutex); + progress(update); + } + + void print_summary(size_t final_series_count) const { + const auto secs = [](TimePoint a, TimePoint b) { return std::chrono::duration(b - a).count(); }; + const auto mb = [](size_t bytes) { return static_cast(bytes) / (1024.0 * 1024.0); }; + double dl = 0, dc = 0, pa = 0, ex = 0; + size_t ev = 0, cb = 0, db = 0; + for (const auto &s : segments) { + dl += s.download_seconds; dc += s.decompress_seconds; + pa += s.parse_seconds; ex += s.extract_seconds; + ev += s.event_count; cb += s.compressed_bytes; db += s.decompressed_bytes; + } + std::cerr << std::fixed << std::setprecision(1) + << "route loaded in " << secs(load_start, load_end) << "s (" << segment_count << " segments, " << num_workers << " workers)\n" + << " resolve: " << secs(load_start, resolve_end) << "s fetch: " << dl << "s (" << mb(cb) << " MB)" + << " decompress: " << dc << "s (" << mb(db) << " MB)\n" + << " parse: " << pa << "s (" << ev << " events) extract: " << ex << "s merge: " << secs(merge_start, merge_end) << "s" + << " series: " << final_series_count << " paths\n"; + for (const auto &s : segments) { + std::cerr << " seg " << std::setw(2) << s.segment_number << ": " + << (s.failed ? "FAILED" : std::to_string(s.download_seconds) + "s + " + std::to_string(s.parse_seconds) + + "s (" + std::to_string(s.event_count) + " ev, " + std::to_string(s.series_count) + " series)") << "\n"; + } + std::cerr.unsetf(std::ios::floatfield); + } + + TimePoint load_start; + TimePoint resolve_end; + TimePoint merge_start; + TimePoint merge_end; + TimePoint load_end; + size_t segment_count = 0; + int num_workers = 1; + std::vector segments; + std::atomic segments_downloaded{0}; + std::atomic segments_parsed{0}; + std::atomic total_segments{0}; + std::atomic bytes_downloaded{0}; + RouteLoadProgressCallback progress; + mutable std::mutex progress_mutex; +}; + +std::string curve_label(std::string_view series_name) { + return std::string(series_name.empty() ? std::string_view{"plot"} : series_name); +} + +bool parse_segment_number(std::string_view value, int *out) { + if (value.empty()) return false; + char *end = nullptr; + const long parsed = std::strtol(std::string(value).c_str(), &end, 10); + if (end == nullptr || *end != '\0') return false; + *out = static_cast(parsed); + return true; +} + +bool is_log_selector_char(char c) { + return c == 'a' || c == 'r' || c == 'q'; +} + +LogSelector parse_log_selector_char(char c) { + switch (c) { + case 'r': return LogSelector::RLog; + case 'q': return LogSelector::QLog; + case 'a': + default: return LogSelector::Auto; + } +} + +const std::string &selected_log_path(const SegmentLogs &segment, LogSelector selector) { + switch (selector) { + case LogSelector::RLog: + return segment.rlog; + case LogSelector::QLog: + return segment.qlog; + case LogSelector::Auto: + default: + return !segment.rlog.empty() ? segment.rlog : segment.qlog; + } +} + +RouteSelection parse_route_selection(std::string route_name) { + RouteSelection route = {}; + route_name = util::strip(route_name); + if (route_name.size() >= 2 && route_name[route_name.size() - 2] == '/' + && is_log_selector_char(static_cast(std::tolower(route_name.back())))) { + route.selector = parse_log_selector_char(static_cast(std::tolower(route_name.back()))); + route.selector_explicit = true; + route_name.resize(route_name.size() - 2); + } + static const std::regex pattern(R"(^(([a-z0-9]{16})[|_/])?(.{20})((--|/)((-?\d+(:(-?\d+)?)?)|(:-?\d+)))?$)"); + std::smatch match; + if (!std::regex_match(route_name, match, pattern)) return route; + + route.dongle_id = match[2].str(); + route.timestamp = match[3].str(); + route.canonical_name = route.dongle_id + "|" + route.timestamp; + + const std::string separator = match[5].str(); + const std::string range_str = match[6].str(); + if (!range_str.empty()) { + route.slice_explicit = true; + if (separator == "/") { + size_t pos = range_str.find(':'); + int begin_segment = 0; + if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + return {}; + } + route.begin_segment = begin_segment; + route.end_segment = begin_segment; + if (pos != std::string::npos) { + int end_segment = -1; + const std::string end_str = range_str.substr(pos + 1); + if (!end_str.empty() && !parse_segment_number(end_str, &end_segment)) { + return {}; + } + route.end_segment = end_str.empty() ? -1 : end_segment; + } + } else if (separator == "--") { + int begin_segment = 0; + if (!parse_segment_number(range_str, &begin_segment)) return {}; + route.begin_segment = begin_segment; + } + } + return route; +} + +void add_log_file_to_segments(std::map *segments, int segment_number, const std::string &file) { + std::string name = extractFileName(file); + const size_t pos = name.find_last_of("--"); + name = pos != std::string::npos ? name.substr(pos + 2) : name; + SegmentLogs &segment = (*segments)[segment_number]; + if (name == "rlog.bz2" || name == "rlog.zst" || name == "rlog") { + segment.rlog = file; + } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { + segment.qlog = file; + } else if (name == "fcamera.hevc") { + segment.fcamera = file; + } else if (name == "dcamera.hevc") { + segment.dcamera = file; + } else if (name == "ecamera.hevc") { + segment.ecamera = file; + } else if (name == "qcamera.ts") { + segment.qcamera = file; + } +} + +std::map trim_segments(std::map segments, const RouteSelection &route) { + if (route.begin_segment > 0) { + segments.erase(segments.begin(), segments.lower_bound(route.begin_segment)); + } + if (route.end_segment >= 0) { + segments.erase(segments.upper_bound(route.end_segment), segments.end()); + } + return segments; +} + +std::map load_segments_from_json(const json11::Json &json) { + std::map segments; + static const std::regex rx(R"(\/(\d+)\/)"); + for (const auto &value : json.object_items()) { + for (const auto &url : value.second.array_items()) { + const std::string url_str = url.string_value(); + std::smatch match; + if (!std::regex_search(url_str, match, rx)) continue; + add_log_file_to_segments(&segments, std::stoi(match[1].str()), url_str); + } + } + return segments; +} + +std::map load_segments_from_server(const RouteSelection &route) { + const std::string result = PyDownloader::getRouteFiles(route.canonical_name); + if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); + + std::string parse_error; + const auto json = json11::Json::parse(result, parse_error); + if (!parse_error.empty()) throw std::runtime_error("Failed to parse route file list for " + route.canonical_name); + if (json.is_object() && json["error"].is_string()) { + throw std::runtime_error("Route API error for " + route.canonical_name + ": " + json["error"].string_value()); + } + return load_segments_from_json(json); +} + +std::map load_segments_from_local(const RouteSelection &route, const std::string &data_dir) { + std::map segments; + const std::string pattern = route.timestamp + "--"; + for (const auto &entry : fs::directory_iterator(data_dir)) { + if (!entry.is_directory()) continue; + const std::string dirname = entry.path().filename().string(); + if (dirname.find(pattern) == std::string::npos) continue; + const size_t marker = dirname.rfind("--"); + if (marker == std::string::npos) continue; + int segment_number = 0; + if (!parse_segment_number(dirname.substr(marker + 2), &segment_number)) { + continue; + } + for (const auto &file : fs::directory_iterator(entry.path())) { + if (file.is_regular_file()) { + add_log_file_to_segments(&segments, segment_number, file.path().string()); + } + } + } + return segments; +} + +RouteIdentifier make_route_identifier(const RouteSelection &route, const std::map &segments) { + RouteIdentifier route_id; + route_id.dongle_id = route.dongle_id; + route_id.log_id = route.timestamp; + route_id.slice_begin = route.begin_segment; + route_id.slice_end = route.end_segment; + route_id.slice_explicit = route.slice_explicit; + route_id.selector = route.selector; + route_id.selector_explicit = route.selector_explicit; + if (!segments.empty()) { + route_id.available_begin = segments.begin()->first; + route_id.available_end = segments.rbegin()->first; + } + return route_id; +} + +std::string detect_dbc_for_fingerprint(std::string_view car_fingerprint) { + return std::string(dbc_for_car_fingerprint(car_fingerprint)); +} + +std::vector available_dbc_names_impl() { + std::set names; + for (const fs::path &dbc_dir : { + repo_root() / "opendbc" / "dbc", + repo_root() / "tools" / "jotpluggler" / "generated_dbcs", + }) { + if (fs::exists(dbc_dir) && fs::is_directory(dbc_dir)) { + for (const auto &entry : fs::directory_iterator(dbc_dir)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") { + continue; + } + names.insert(entry.path().stem().string()); + } + } + } + for (const auto &[_, dbc_name] : kCarFingerprintToDbc) { + if (!dbc_name.empty()) { + names.insert(std::string(dbc_name)); + } + } + return std::vector(names.begin(), names.end()); +} + +fs::path resolve_dbc_path(const std::string &dbc_name) { + for (const fs::path &candidate : { + repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), + repo_root() / "tools" / "jotpluggler" / "generated_dbcs" / (dbc_name + ".dbc"), + }) { + if (fs::exists(candidate)) return candidate; + } + throw std::runtime_error("DBC not found: " + dbc_name); +} + +std::array parse_color(std::string_view color) { + if (!color.empty() && color.front() == '#') { + color.remove_prefix(1); + } + if (color.size() != 6) return {160, 170, 180}; + + std::array out = {}; + for (size_t i = 0; i < 3; ++i) { + const std::string byte(color.substr(i * 2, 2)); + char *end = nullptr; + const long parsed = std::strtol(byte.c_str(), &end, 16); + if (end == nullptr || *end != '\0' || parsed < 0 || parsed > 255) return {160, 170, 180}; + out[i] = static_cast(parsed); + } + return out; +} + +uint8_t android_priority_to_level(uint8_t priority) { + switch (priority) { + case 2: + case 3: + return 10; + case 4: + return 20; + case 5: + return 30; + case 6: + return 40; + case 7: + default: + return 50; + } +} + +uint8_t alert_status_to_level(cereal::SelfdriveState::AlertStatus status) { + switch (status) { + case cereal::SelfdriveState::AlertStatus::NORMAL: + return 20; + case cereal::SelfdriveState::AlertStatus::USER_PROMPT: + return 30; + case cereal::SelfdriveState::AlertStatus::CRITICAL: + return 40; + } + return 20; +} + +TimelineEntry::Type alert_status_to_timeline_type(cereal::SelfdriveState::AlertStatus status, bool enabled) { + if (!enabled) { + return TimelineEntry::Type::None; + } + switch (status) { + case cereal::SelfdriveState::AlertStatus::NORMAL: + return TimelineEntry::Type::Engaged; + case cereal::SelfdriveState::AlertStatus::USER_PROMPT: + return TimelineEntry::Type::AlertInfo; + case cereal::SelfdriveState::AlertStatus::CRITICAL: + return TimelineEntry::Type::AlertCritical; + } + return TimelineEntry::Type::Engaged; +} + +void append_timeline_entry(std::vector *timeline, double mono_time, TimelineEntry::Type type) { + if (timeline == nullptr) { + return; + } + if (!timeline->empty() && timeline->back().type == type) { + timeline->back().end_time = std::max(timeline->back().end_time, mono_time); + return; + } + timeline->push_back(TimelineEntry{ + .start_time = mono_time, + .end_time = mono_time, + .type = type, + }); +} + +double android_wall_time_seconds(uint64_t timestamp) { + if (timestamp == 0) return 0.0; + if (timestamp > 1000000000000ULL) return static_cast(timestamp) / 1.0e9; + if (timestamp > 1000000000ULL) return static_cast(timestamp) / 1.0e6; + return static_cast(timestamp); +} + +std::optional json_u64_value(const json11::Json &value) { + if (value.is_number()) { + const double number = value.number_value(); + if (number >= 0.0) return static_cast(number); + } + if (value.is_string()) { + try { + return static_cast(std::stoull(value.string_value())); + } catch (...) { + } + } + return std::nullopt; +} + +std::optional json_int_value(const json11::Json &value) { + if (value.is_number()) return value.int_value(); + if (value.is_string()) { + try { + return std::stoi(value.string_value()); + } catch (...) { + } + } + return std::nullopt; +} + +std::string json_value_for_log(const json11::Json &value) { + if (value.is_string()) return value.string_value(); + if (value.is_bool()) return value.bool_value() ? "true" : "false"; + return value.dump(); +} + +std::string format_journal_context(const json11::Json &parsed, int pid, int tid) { + std::vector lines; + if (pid != 0 || tid != 0) { + lines.push_back("pid=" + std::to_string(pid) + ", tid=" + std::to_string(tid)); + } + + const std::array preferred_keys = { + "_HOSTNAME", + "_TRANSPORT", + "PRIORITY", + "SYSLOG_FACILITY", + "__MONOTONIC_TIMESTAMP", + }; + for (const char *key : preferred_keys) { + const json11::Json &value = parsed[key]; + if (!value.is_null()) { + lines.push_back(std::string(key) + "=" + json_value_for_log(value)); + } + } + return join(lines, "\n"); +} + +std::string alert_message_text(const cereal::SelfdriveState::Reader &state) { + std::string text = state.getAlertText1().cStr(); + const std::string text2 = state.getAlertText2().cStr(); + if (!text2.empty()) { + text += " - " + text2; + } + return text; +} + +bool same_log_entry(const LogEntry &a, const LogEntry &b) { + return a.mono_time == b.mono_time + && a.level == b.level + && a.source == b.source + && a.func == b.func + && a.message == b.message + && a.context == b.context + && a.origin == b.origin; +} + +void append_log_event(cereal::Event::Which which, + const cereal::Event::Reader &event, + double time_offset, + std::vector *logs, + std::string *last_alert_key) { + const double boot_time = static_cast(event.getLogMonoTime()) / 1.0e9; + const double mono_time = boot_time - time_offset; + + auto make_entry = [&](LogOrigin origin, uint8_t level = 20) { + LogEntry e; + e.mono_time = mono_time; + e.boot_time = boot_time; + e.origin = origin; + e.level = level; + return e; + }; + + switch (which) { + case cereal::Event::Which::LOG_MESSAGE: + case cereal::Event::Which::ERROR_LOG_MESSAGE: { + const std::string raw = which == cereal::Event::Which::LOG_MESSAGE + ? event.getLogMessage().cStr() : event.getErrorLogMessage().cStr(); + auto entry = make_entry(LogOrigin::Log, which == cereal::Event::Which::ERROR_LOG_MESSAGE ? 40 : 20); + entry.source = "log"; + entry.message = raw; + std::string err; + if (const auto p = json11::Json::parse(raw, err); err.empty() && p.is_object()) { + entry.wall_time = p["created"].number_value(); + if (p["levelnum"].is_number()) entry.level = static_cast(p["levelnum"].int_value()); + const std::string fn = p["filename"].string_value(); + const int ln = p["lineno"].is_number() ? p["lineno"].int_value() : 0; + entry.source = fn.empty() ? "log" : fn + (ln > 0 ? ":" + std::to_string(ln) : ""); + entry.func = p["funcname"].string_value(); + if (p["msg"].is_string()) entry.message = p["msg"].string_value(); + if (!p["ctx"].is_null()) entry.context = p["ctx"].dump(); + } + logs->push_back(std::move(entry)); + break; + } + case cereal::Event::Which::ANDROID_LOG: { + const auto android = event.getAndroidLog(); + auto entry = make_entry(LogOrigin::Android, android_priority_to_level(android.getPriority())); + entry.wall_time = android_wall_time_seconds(android.getTs()); + entry.source = android.hasTag() ? android.getTag().cStr() : "android"; + entry.message = android.hasMessage() ? android.getMessage().cStr() : std::string(); + entry.context = "pid=" + std::to_string(android.getPid()) + ", tid=" + std::to_string(android.getTid()); + if (!entry.message.empty()) { + std::string err; + if (const auto p = json11::Json::parse(entry.message, err); err.empty() && p.is_object()) { + if (p["MESSAGE"].is_string()) entry.message = p["MESSAGE"].string_value(); + if (p["SYSLOG_IDENTIFIER"].is_string() && !p["SYSLOG_IDENTIFIER"].string_value().empty()) + entry.source = p["SYSLOG_IDENTIFIER"].string_value(); + if (auto pri = json_int_value(p["PRIORITY"]); pri.has_value()) + entry.level = android_priority_to_level(*pri); + if (auto ts = json_u64_value(p["__REALTIME_TIMESTAMP"]); ts.has_value()) + entry.wall_time = android_wall_time_seconds(*ts); + entry.context = format_journal_context(p, android.getPid(), android.getTid()); + } + } + logs->push_back(std::move(entry)); + break; + } + case cereal::Event::Which::SELFDRIVE_STATE: { + const auto sd = event.getSelfdriveState(); + const std::string alert_type = sd.getAlertType().cStr(); + const std::string alert_text1 = sd.getAlertText1().cStr(); + if (alert_text1.empty() && alert_type.empty()) break; + const std::string key = alert_type + "\n" + alert_text1 + "\n" + std::string(sd.getAlertText2().cStr()); + if (last_alert_key != nullptr && key == *last_alert_key) break; + if (last_alert_key != nullptr) *last_alert_key = key; + auto entry = make_entry(LogOrigin::Alert, alert_status_to_level(sd.getAlertStatus())); + entry.source = "alert"; + entry.func = alert_type; + entry.message = alert_message_text(sd); + logs->push_back(std::move(entry)); + break; + } + default: + break; + } +} + +std::vector extract_segment_timeline(const std::vector &events) { + std::vector timeline; + timeline.reserve(events.size() / 16); + + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::SELFDRIVE_STATE) { + continue; + } + capnp::FlatArrayMessageReader event_reader(event_record.data); + const cereal::Event::Reader event = event_reader.getRoot(); + const auto sd = event.getSelfdriveState(); + const double mono_time = static_cast(event.getLogMonoTime()) / 1.0e9; + append_timeline_entry(&timeline, mono_time, alert_status_to_timeline_type(sd.getAlertStatus(), sd.getEnabled())); + } + + return timeline; +} + +std::vector extract_segment_logs(const std::vector &events) { + std::vector logs; + logs.reserve(events.size() / 8); + std::string last_alert_key; + + for (const Event &event_record : events) { + capnp::FlatArrayMessageReader event_reader(event_record.data); + const cereal::Event::Reader event = event_reader.getRoot(); + append_log_event(event_record.which, event, 0.0, &logs, &last_alert_key); + } + + return logs; +} + +RouteMetadata extract_segment_metadata(const std::vector &events) { + RouteMetadata metadata; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::CAR_PARAMS) continue; + capnp::FlatArrayMessageReader event_reader(event_record.data); + const cereal::Event::Reader event = event_reader.getRoot(); + metadata.car_fingerprint = event.getCarParams().getCarFingerprint().cStr(); + if (!metadata.car_fingerprint.empty()) break; + } + return metadata; +} + +RouteMetadata detect_route_metadata(const std::map &segments, LogSelector selector) { + for (const auto &[_, segment] : segments) { + const std::string &log_path = selector == LogSelector::Auto + ? (!segment.qlog.empty() ? segment.qlog : segment.rlog) + : selected_log_path(segment, selector); + if (log_path.empty()) { + continue; + } + LogReader reader; + if (!reader.load(log_path, nullptr, true)) continue; + RouteMetadata metadata = extract_segment_metadata(reader.events); + if (!metadata.car_fingerprint.empty()) return metadata; + } + return {}; +} + +std::vector normalize_sizes(const json11::Json &sizes_json, size_t child_count) { + std::vector parsed; + if (sizes_json.is_array()) { + for (const json11::Json &value : sizes_json.array_items()) { + if (value.is_number()) { + parsed.push_back(std::max(value.number_value(), 0.0)); + } + } + } + + if (parsed.size() != child_count || child_count == 0) return std::vector(child_count, child_count == 0 ? 0.0 : 1.0 / static_cast(child_count)); + + const double total = std::accumulate(parsed.begin(), parsed.end(), 0.0); + if (total <= 0.0) return std::vector(child_count, 1.0 / static_cast(child_count)); + for (double &value : parsed) { + value /= total; + } + return parsed; +} + +PlotRange parse_range(const json11::Json &pane_node) { + PlotRange range; + const json11::Json &range_node = pane_node["range"]; + if (range_node.is_object()) { + range.valid = true; + range.left = range_node["left"].number_value(); + range.right = range_node["right"].number_value(); + range.bottom = range_node["bottom"].number_value(); + range.top = range_node["top"].is_number() ? range_node["top"].number_value() : 1.0; + } + const json11::Json &limit_y_node = pane_node["y_limits"]; + if (limit_y_node.is_object()) { + if (limit_y_node["min"].is_number()) { + range.has_y_limit_min = true; + range.y_limit_min = limit_y_node["min"].number_value(); + } + if (limit_y_node["max"].is_number()) { + range.has_y_limit_max = true; + range.y_limit_max = limit_y_node["max"].number_value(); + } + } + return range; +} + +Curve parse_curve(const json11::Json &curve_node) { + Curve curve; + curve.name = curve_node["name"].string_value(); + curve.label = curve_label(curve.name); + curve.color = parse_color(curve_node["color"].string_value()); + + const std::string transform_name = curve_node["transform"].string_value(); + if (transform_name == "derivative") { + curve.derivative = true; + curve.derivative_dt = curve_node["derivative_dt"].is_number() ? curve_node["derivative_dt"].number_value() : 0.0; + } else if (transform_name == "scale") { + curve.value_scale = curve_node["scale"].is_number() ? curve_node["scale"].number_value() : 1.0; + curve.value_offset = curve_node["offset"].is_number() ? curve_node["offset"].number_value() : 0.0; + } + const json11::Json &custom_node = curve_node["custom_python"]; + if (custom_node.is_object()) { + CustomPythonSeries spec; + spec.linked_source = custom_node["linked_source"].string_value(); + spec.globals_code = custom_node["globals_code"].string_value(); + spec.function_code = custom_node["function_code"].string_value(); + for (const json11::Json &source : custom_node["additional_sources"].array_items()) { + if (source.is_string()) { + spec.additional_sources.push_back(source.string_value()); + } + } + curve.custom_python = std::move(spec); + } + return curve; +} + +std::string pane_title(const json11::Json &dock_area_node) { + const std::string raw = dock_area_node["title"].string_value(); + return raw.empty() ? "..." : raw; +} + +Pane parse_dock_area(const json11::Json &dock_area_node) { + Pane pane; + const std::string kind = dock_area_node["kind"].string_value(); + if (kind == "map") { + pane.kind = PaneKind::Map; + } else if (kind == "camera") { + pane.kind = PaneKind::Camera; + const std::string camera_view = dock_area_node["camera_view"].string_value(); + if (const CameraViewSpec *spec = camera_view_spec_from_layout_name(camera_view)) { + pane.camera_view = spec->view; + } else { + pane.camera_view = CameraViewKind::Road; + } + } + pane.range = parse_range(dock_area_node); + const json11::Json &curves_node = dock_area_node["curves"]; + if (curves_node.is_array()) { + for (const json11::Json &curve_node : curves_node.array_items()) { + if (curve_node.is_object()) { + pane.curves.push_back(parse_curve(curve_node)); + } + } + } + pane.title = pane_title(dock_area_node); + return pane; +} + +WorkspaceNode parse_workspace_node(const json11::Json &node, WorkspaceTab *tab) { + WorkspaceNode workspace_node; + if (!node.is_object()) return workspace_node; + + if (node["curves"].is_array()) { + workspace_node.is_pane = true; + workspace_node.pane_index = static_cast(tab->panes.size()); + tab->panes.push_back(parse_dock_area(node)); + return workspace_node; + } + + const json11::Json &children_node = node["children"]; + if (!children_node.is_array()) return workspace_node; + + const std::vector children = children_node.array_items(); + if (children.empty()) return workspace_node; + + const std::string split = node["split"].string_value(); + workspace_node.orientation = split == "vertical" ? SplitOrientation::Vertical : SplitOrientation::Horizontal; + const std::vector sizes = normalize_sizes(node["sizes"], children.size()); + workspace_node.sizes.reserve(sizes.size()); + workspace_node.children.reserve(children.size()); + for (size_t i = 0; i < children.size(); ++i) { + workspace_node.sizes.push_back(static_cast(sizes[i])); + workspace_node.children.push_back(parse_workspace_node(children[i], tab)); + } + return workspace_node; +} + +WorkspaceTab parse_tab(const json11::Json &tab, const fs::path &layout_path) { + WorkspaceTab workspace_tab; + workspace_tab.tab_name = tab["name"].string_value().empty() ? "tab1" : tab["name"].string_value(); + const json11::Json &dock_root = tab["root"]; + if (!dock_root.is_object()) throw std::runtime_error("Layout tab has no dock content: " + layout_path.string()); + workspace_tab.root = parse_workspace_node(dock_root, &workspace_tab); + return workspace_tab; +} + +SketchLayout parse_layout(const fs::path &layout_path) { + const std::string text = util::read_file(layout_path.string()); + if (text.empty()) throw std::runtime_error("Failed to read layout JSON: " + layout_path.string()); + + std::string parse_error; + const json11::Json root = json11::Json::parse(text, parse_error); + if (!parse_error.empty() || !root.is_object()) { + throw std::runtime_error("Failed to parse layout JSON: " + layout_path.string()); + } + SketchLayout layout; + for (const json11::Json &tab : root["tabs"].array_items()) { + if (tab.is_object()) { + layout.tabs.push_back(parse_tab(tab, layout_path)); + } + } + if (layout.tabs.empty()) throw std::runtime_error("Layout has no tabs: " + layout_path.string()); + const json11::Json &tab_index = root["current_tab_index"].is_number() ? root["current_tab_index"] : root["currentTabIndex"]; + layout.current_tab_index = std::clamp(tab_index.is_number() ? tab_index.int_value() : 0, + 0, + static_cast(layout.tabs.size()) - 1); + return layout; +} + +ScalarKind scalar_kind_for_type(const capnp::Type &type) { + if (type.isBool()) return ScalarKind::Bool; + if (type.isInt8() || type.isInt16() || type.isInt32() || type.isInt64()) { + return ScalarKind::Int; + } + if (type.isUInt8() || type.isUInt16() || type.isUInt32() || type.isUInt64()) { + return ScalarKind::UInt; + } + if (type.isFloat32() || type.isFloat64()) { + return ScalarKind::Float; + } + if (type.isEnum()) return ScalarKind::Enum; + return ScalarKind::None; +} + +ResolvedNode build_resolved_type(const capnp::Type &type, + bool has_field, + capnp::StructSchema::Field field, + std::string segment, + std::string path, + size_t *next_fixed_slot, + std::vector *fixed_paths, + bool dynamic_path = false) { + ResolvedNode node; + node.has_field = has_field; + node.field = field; + node.segment = std::move(segment); + node.path = std::move(path); + node.scalar_kind = scalar_kind_for_type(type); + if (node.scalar_kind != ScalarKind::None) { + node.kind = ResolvedNodeKind::Scalar; + if (!dynamic_path) { + node.fixed_slot = static_cast((*next_fixed_slot)++); + fixed_paths->push_back(node.path); + } + return node; + } + + if (type.isStruct()) { + node.kind = ResolvedNodeKind::Struct; + for (auto child : type.asStruct().getFields()) { + const std::string child_segment = child.getProto().getName().cStr(); + node.children.push_back(build_resolved_type( + child.getType(), + true, + child, + child_segment, + node.path + "/" + child_segment, + next_fixed_slot, + fixed_paths, + dynamic_path)); + } + return node; + } + + if (type.isList()) { + const capnp::Type element_type = type.asList().getElementType(); + if (element_type.isText() || element_type.isData() || element_type.isInterface() || element_type.isAnyPointer()) { + node.kind = ResolvedNodeKind::Ignore; + return node; + } + node.kind = ResolvedNodeKind::List; + node.skip_large_scalar_list = scalar_kind_for_type(element_type) != ScalarKind::None; + node.element = std::make_unique( + build_resolved_type(element_type, + false, + capnp::StructSchema::Field(), + "", + node.path, + next_fixed_slot, + fixed_paths, + true)); + return node; + } + + node.kind = ResolvedNodeKind::Ignore; + return node; +} + +int register_fixed_series_path(const std::string &path, + size_t *next_fixed_slot, + std::vector *fixed_paths) { + const int slot = static_cast((*next_fixed_slot)++); + fixed_paths->push_back(path); + return slot; +} + +const SchemaIndex &SchemaIndex::instance() { + static const SchemaIndex index = [] { + SchemaIndex out; + const auto event_schema = capnp::Schema::from().asStruct(); + uint16_t max_discriminant = 0; + for (auto union_field : event_schema.getUnionFields()) { + max_discriminant = std::max(max_discriminant, union_field.getProto().getDiscriminantValue()); + } + out.by_which.resize(static_cast(max_discriminant) + 1); + size_t next_fixed_slot = 0; + for (auto union_field : event_schema.getUnionFields()) { + ResolvedService service; + service.event_which = union_field.getProto().getDiscriminantValue(); + service.union_field = union_field; + service.service_name = union_field.getProto().getName().cStr(); + service.valid_slot = register_fixed_series_path( + "/" + service.service_name + "/valid", &next_fixed_slot, &out.fixed_paths); + service.log_mono_time_slot = register_fixed_series_path( + "/" + service.service_name + "/logMonoTime", &next_fixed_slot, &out.fixed_paths); + service.seconds_slot = register_fixed_series_path( + "/" + service.service_name + "/t", &next_fixed_slot, &out.fixed_paths); + service.payload = build_resolved_type( + union_field.getType(), + false, + capnp::StructSchema::Field(), + service.service_name, + "/" + service.service_name, + &next_fixed_slot, + &out.fixed_paths); + out.by_which[service.event_which] = std::move(service); + } + out.fixed_series_count = next_fixed_slot; + return out; + }(); + return index; +} + +bool is_absolute_curve(const std::string &name) { + return !name.empty() && name.front() == '/'; +} + +std::optional scalar_value_to_double(const capnp::DynamicValue::Reader &value, ScalarKind kind) { + switch (kind) { + case ScalarKind::Bool: + return value.as() ? 1.0 : 0.0; + case ScalarKind::Int: + return static_cast(value.as()); + case ScalarKind::UInt: + return static_cast(value.as()); + case ScalarKind::Float: + return value.as(); + case ScalarKind::Enum: + return static_cast(value.as().getRaw()); + case ScalarKind::None: + return std::nullopt; + } + return std::nullopt; +} + +void capture_enum_info(const std::string &path, + const capnp::DynamicValue::Reader &value, + SeriesAccumulator *series) { + if (series->enum_info.find(path) != series->enum_info.end()) { + return; + } + + const auto dynamic_enum = value.as(); + EnumInfo info; + for (auto enumerant : dynamic_enum.getSchema().getEnumerants()) { + const uint16_t ordinal = enumerant.getOrdinal(); + if (ordinal >= info.names.size()) { + info.names.resize(static_cast(ordinal) + 1); + } + info.names[ordinal] = enumerant.getProto().getName().cStr(); + } + if (!info.names.empty()) { + series->enum_info.emplace(path, std::move(info)); + } +} + +void append_scalar_point(RouteSeries *series, + const std::string &path, + double tm, + double value) { + if (series->path.empty()) { + series->path = path; + } + series->times.push_back(tm); + series->values.push_back(value); +} + +void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { + series->times.push_back(tm); + series->values.push_back(value); +} + +CanMessageData *ensure_can_message(CanServiceKind service, uint8_t bus, uint32_t address, SeriesAccumulator *series) { + const CanMessageId id{service, bus, address}; + auto [it, inserted] = series->can_message_slots.try_emplace(id, series->can_messages.size()); + if (inserted) { + series->can_messages.push_back(CanMessageData{.id = id}); + } + return &series->can_messages[it->second]; +} + +void append_can_frame(CanServiceKind service, + uint8_t bus, + uint32_t address, + uint16_t bus_time, + capnp::Data::Reader dat, + double tm, + SeriesAccumulator *series) { + CanMessageData *message = ensure_can_message(service, bus, address, series); + message->samples.push_back(CanFrameSample{ + .mono_time = tm, + .bus_time = bus_time, + .data = std::string(reinterpret_cast(dat.begin()), dat.size()), + }); +} + +void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); + +void decode_can_frame(const dbc::Database *can_dbc, + const std::string &service_name, + uint8_t bus, + uint32_t address, + const uint8_t *raw, + size_t data_size, + double tm, + SeriesAccumulator *series) { + if (can_dbc == nullptr) { + return; + } + const dbc::Message *message = can_dbc->message(address); + if (message == nullptr) { + return; + } + const std::string base_path = "/" + service_name + "/" + std::to_string(bus) + "/" + message->name; + for (const dbc::Signal &signal : message->signals) { + std::optional value = dbc::signalValue(signal, *message, raw, data_size); + if (!value.has_value()) continue; + const std::string path = base_path + "/" + signal.name; + append_dynamic_scalar_point(path, tm, *value, series); + if (series->enum_info.find(path) == series->enum_info.end()) { + std::vector enum_names = can_dbc->enumNames(signal); + if (!enum_names.empty()) { + series->enum_info.emplace(path, EnumInfo{.names = std::move(enum_names)}); + } + } + } +} + +void append_live_can_frame(CanServiceKind service, + const LiveCanFrame &frame, + double time_offset, + const dbc::Database *can_dbc, + SeriesAccumulator *series) { + const double tm = frame.mono_time - time_offset; + CanMessageData *message = ensure_can_message(service, frame.bus, frame.address, series); + message->samples.push_back(CanFrameSample{ + .mono_time = tm, + .bus_time = frame.bus_time, + .data = frame.data, + }); + decode_can_frame(can_dbc, + service == CanServiceKind::Can ? "can" : "sendcan", + frame.bus, + frame.address, + reinterpret_cast(frame.data.data()), + frame.data.size(), + tm, + series); +} + +SeriesAccumulator make_series_accumulator(const SchemaIndex &schema) { + SeriesAccumulator out(schema.fixed_series_count); + for (size_t i = 0; i < schema.fixed_paths.size(); ++i) { + out.fixed_series[i].path = schema.fixed_paths[i]; + } + return out; +} + +size_t ensure_dynamic_slot(const std::string &path, SeriesAccumulator *series) { + auto [it, inserted] = series->dynamic_slots.try_emplace(path, series->dynamic_series.size()); + if (inserted) { + series->dynamic_series.push_back(RouteSeries{it->first}); + } + return it->second; +} + +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series) { + return &series->dynamic_series[ensure_dynamic_slot(path, series)]; +} + +RouteSeries *ensure_list_scalar_series(const std::string &base_path, size_t index, SeriesAccumulator *series) { + auto [it, _] = series->list_scalar_slots.try_emplace(base_path); + std::vector &slots = it->second; + if (slots.size() <= index) { + slots.resize(index + 1, INVALID_DYNAMIC_SLOT); + } + if (slots[index] == INVALID_DYNAMIC_SLOT) { + slots[index] = ensure_dynamic_slot(base_path + "/" + std::to_string(index), series); + } + return &series->dynamic_series[slots[index]]; +} + +void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series) { + append_scalar_point(ensure_dynamic_series(path, series), path, tm, value); +} + +void append_scalar_value(const ResolvedNode &node, + const std::string *path_override, + const capnp::DynamicValue::Reader &raw_value, + double tm, + double value, + SeriesAccumulator *series) { + if (path_override == nullptr && node.fixed_slot >= 0) { + if (node.scalar_kind == ScalarKind::Enum) { + capture_enum_info(node.path, raw_value, series); + } + append_fixed_scalar_point(&series->fixed_series[static_cast(node.fixed_slot)], tm, value); + return; + } + + const std::string &path = path_override != nullptr ? *path_override : node.path; + if (node.scalar_kind == ScalarKind::Enum) { + capture_enum_info(path, raw_value, series); + } + append_dynamic_scalar_point(path, tm, value, series); +} + +void append_fast_node(const ResolvedNode &node, + const capnp::DynamicValue::Reader &value, + double tm, + SeriesAccumulator *series, + const std::string *path_override = nullptr) { + switch (node.kind) { + case ResolvedNodeKind::Scalar: { + if (std::optional scalar = scalar_value_to_double(value, node.scalar_kind); scalar.has_value()) { + append_scalar_value(node, path_override, value, tm, *scalar, series); + } + return; + } + case ResolvedNodeKind::Struct: { + const capnp::DynamicStruct::Reader reader = value.as(); + for (const ResolvedNode &child : node.children) { + if (!child.has_field || !reader.has(child.field)) continue; + if (path_override == nullptr) { + append_fast_node(child, reader.get(child.field), tm, series, nullptr); + } else { + const std::string child_path = child.segment.empty() ? *path_override : (*path_override + "/" + child.segment); + append_fast_node(child, reader.get(child.field), tm, series, &child_path); + } + } + return; + } + case ResolvedNodeKind::List: { + if (!node.element) { + return; + } + const capnp::DynamicList::Reader list = value.as(); + if (list.size() == 0) { + return; + } + if (node.skip_large_scalar_list && list.size() > 16) { + return; + } + const std::string &base_path = path_override != nullptr ? *path_override : node.path; + if (node.element->kind == ResolvedNodeKind::Scalar) { + for (uint i = 0; i < list.size(); ++i) { + if (std::optional scalar = scalar_value_to_double(list[i], node.element->scalar_kind); scalar.has_value()) { + RouteSeries *item_series = ensure_list_scalar_series(base_path, i, series); + if (node.element->scalar_kind == ScalarKind::Enum && !item_series->path.empty()) { + capture_enum_info(item_series->path, list[i], series); + } + append_fixed_scalar_point(item_series, tm, *scalar); + } + } + return; + } + for (uint i = 0; i < list.size(); ++i) { + const std::string item_path = base_path + "/" + std::to_string(i); + append_fast_node(*node.element, list[i], tm, series, &item_path); + } + return; + } + case ResolvedNodeKind::Ignore: + return; + } +} + +void append_event_fast(cereal::Event::Which which, + int32_t eidx_segnum, + kj::ArrayPtr data, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + double time_offset, + SeriesAccumulator *series) { + if (eidx_segnum != -1) { + return; + } + const uint16_t which_index = static_cast(which); + if (which_index >= schema.by_which.size() || !schema.by_which[which_index].has_value()) { + return; + } + const ResolvedService &service = *schema.by_which[which_index]; + capnp::FlatArrayMessageReader event_reader(data); + const cereal::Event::Reader event = event_reader.getRoot(); + const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset; + append_fixed_scalar_point(&series->fixed_series[static_cast(service.valid_slot)], + tm, + event.getValid() ? 1.0 : 0.0); + append_fixed_scalar_point(&series->fixed_series[static_cast(service.log_mono_time_slot)], + tm, + static_cast(event.getLogMonoTime())); + append_fixed_scalar_point(&series->fixed_series[static_cast(service.seconds_slot)], + tm, + tm); + if (service.service_name == "can" || service.service_name == "sendcan") { + const CanServiceKind can_service = service.service_name == "can" + ? CanServiceKind::Can + : CanServiceKind::Sendcan; + auto decode_message = [&](uint8_t bus, uint32_t address, const auto &dat_reader) { + const auto bytes = dat_reader.begin(); + decode_can_frame(can_dbc, service.service_name, bus, address, bytes, dat_reader.size(), tm, series); + }; + if (service.service_name == "can") { + for (const auto &msg : event.getCan()) { + append_can_frame(can_service, + static_cast(msg.getSrc()), + msg.getAddress(), + msg.getBusTimeDEPRECATED(), + msg.getDat(), + tm, + series); + if (!skip_raw_can) continue; + decode_message(static_cast(msg.getSrc()), msg.getAddress(), msg.getDat()); + } + } else { + for (const auto &msg : event.getSendcan()) { + append_can_frame(can_service, + static_cast(msg.getSrc()), + msg.getAddress(), + msg.getBusTimeDEPRECATED(), + msg.getDat(), + tm, + series); + if (!skip_raw_can) continue; + decode_message(static_cast(msg.getSrc()), msg.getAddress(), msg.getDat()); + } + } + if (skip_raw_can) { + return; + } + } + + const capnp::DynamicStruct::Reader dynamic_event(event); + append_fast_node(service.payload, dynamic_event.get(service.union_field), tm, series); +} + +void append_events_fast_range(const std::vector &events, + size_t begin, + size_t end, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + SeriesAccumulator *series) { + for (size_t i = begin; i < end; ++i) { + const Event &event_record = events[i]; + append_event_fast(event_record.which, + event_record.eidx_segnum, + event_record.data, + schema, + can_dbc, + skip_raw_can, + 0.0, + series); + } +} + +void merge_route_series(RouteSeries *dst, RouteSeries *src) { + if (src->times.empty()) { + return; + } + if (dst->times.empty()) { + *dst = std::move(*src); + return; + } + + dst->times.reserve(dst->times.size() + src->times.size()); + dst->values.reserve(dst->values.size() + src->values.size()); + dst->times.insert(dst->times.end(), src->times.begin(), src->times.end()); + dst->values.insert(dst->values.end(), src->values.begin(), src->values.end()); +} + +void merge_can_message_data(CanMessageData *dst, CanMessageData *src) { + if (src->samples.empty()) { + return; + } + if (dst->samples.empty()) { + *dst = std::move(*src); + return; + } + dst->samples.reserve(dst->samples.size() + src->samples.size()); + dst->samples.insert(dst->samples.end(), + std::make_move_iterator(src->samples.begin()), + std::make_move_iterator(src->samples.end())); +} + +void merge_series_accumulator(SeriesAccumulator *dst, SeriesAccumulator *src) { + if (dst->fixed_series.size() != src->fixed_series.size()) { + throw std::runtime_error("Fixed-series slot count mismatch during merge"); + } + + for (size_t i = 0; i < dst->fixed_series.size(); ++i) { + merge_route_series(&dst->fixed_series[i], &src->fixed_series[i]); + } + for (auto &series : src->dynamic_series) { + if (series.path.empty()) continue; + RouteSeries &dst_series = dst->dynamic_series[ensure_dynamic_slot(series.path, dst)]; + merge_route_series(&dst_series, &series); + } + for (auto &message : src->can_messages) { + CanMessageData &dst_message = *ensure_can_message(message.id.service, message.id.bus, message.id.address, dst); + merge_can_message_data(&dst_message, &message); + } + for (auto &[path, info] : src->enum_info) { + dst->enum_info.try_emplace(path, std::move(info)); + } +} + +size_t populated_series_count(const SeriesAccumulator &series) { + size_t count = 0; + for (const RouteSeries &fixed : series.fixed_series) { + count += !fixed.times.empty(); + } + for (const RouteSeries &dynamic : series.dynamic_series) { + count += !dynamic.times.empty(); + } + return count; +} + +bool series_is_sorted(const RouteSeries &series) { + for (size_t i = 1; i < series.times.size(); ++i) { + if (series.times[i] < series.times[i - 1]) return false; + } + return true; +} + +void sort_series_by_time(RouteSeries *series) { + if (series->times.size() <= 1 || series_is_sorted(*series)) { + return; + } + std::vector order(series->times.size()); + std::iota(order.begin(), order.end(), 0); + std::sort(order.begin(), order.end(), [&](size_t a, size_t b) { + return series->times[a] < series->times[b]; + }); + + std::vector sorted_times(series->times.size()); + std::vector sorted_values(series->values.size()); + for (size_t i = 0; i < order.size(); ++i) { + sorted_times[i] = series->times[order[i]]; + sorted_values[i] = series->values[order[i]]; + } + series->times = std::move(sorted_times); + series->values = std::move(sorted_values); +} + +std::vector collect_series(SeriesAccumulator &&series) { + std::vector out; + out.reserve(series.fixed_series.size() + series.dynamic_series.size()); + for (auto &fixed : series.fixed_series) { + sort_series_by_time(&fixed); + if (!fixed.times.empty()) { + out.push_back(std::move(fixed)); + } + } + for (auto &dynamic : series.dynamic_series) { + sort_series_by_time(&dynamic); + if (!dynamic.times.empty()) { + out.push_back(std::move(dynamic)); + } + } + return out; +} + +RouteData build_route_data(std::vector &&series_list, + std::vector &&can_messages, + std::vector &&logs, + std::vector &&timeline, + std::unordered_map &&enum_info, + std::string car_fingerprint, + std::string dbc_name) { + RouteData route_data; + route_data.series.reserve(series_list.size()); + route_data.paths.reserve(series_list.size()); + for (RouteSeries &series : series_list) { + if (series.times.empty()) continue; + route_data.has_time_range = true; + route_data.x_min = route_data.series.empty() ? series.times.front() : std::min(route_data.x_min, series.times.front()); + route_data.x_max = route_data.series.empty() ? series.times.back() : std::max(route_data.x_max, series.times.back()); + route_data.paths.push_back(series.path); + route_data.series.push_back(std::move(series)); + } + + std::sort(route_data.paths.begin(), route_data.paths.end()); + std::sort(route_data.series.begin(), route_data.series.end(), [](const RouteSeries &a, const RouteSeries &b) { + return a.path < b.path; + }); + std::sort(logs.begin(), logs.end(), [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + logs.erase(std::unique(logs.begin(), logs.end(), [](const LogEntry &a, const LogEntry &b) { + return same_log_entry(a, b); + }), + logs.end()); + + std::vector deduped_logs; + deduped_logs.reserve(logs.size()); + for (LogEntry &entry : logs) { + if (!deduped_logs.empty() + && entry.origin == LogOrigin::Alert + && deduped_logs.back().origin == LogOrigin::Alert + && deduped_logs.back().func == entry.func + && deduped_logs.back().message == entry.message) { + continue; + } + deduped_logs.push_back(std::move(entry)); + } + route_data.logs = std::move(deduped_logs); + + if (!route_data.has_time_range && !route_data.logs.empty()) { + route_data.has_time_range = true; + route_data.x_min = route_data.logs.front().mono_time; + route_data.x_max = route_data.logs.back().mono_time; + } + if (!route_data.has_time_range) { + bool initialized = false; + for (const CanMessageData &message : can_messages) { + if (message.samples.empty()) continue; + if (!initialized) { + route_data.x_min = message.samples.front().mono_time; + route_data.x_max = message.samples.back().mono_time; + initialized = true; + } else { + route_data.x_min = std::min(route_data.x_min, message.samples.front().mono_time); + route_data.x_max = std::max(route_data.x_max, message.samples.back().mono_time); + } + } + route_data.has_time_range = initialized; + } + if (!route_data.has_time_range && !timeline.empty()) { + route_data.has_time_range = true; + route_data.x_min = timeline.front().start_time; + route_data.x_max = timeline.back().end_time; + } + + if (route_data.has_time_range) { + const double time_offset = route_data.x_min; + for (RouteSeries &series : route_data.series) { + for (double &tm : series.times) { + tm -= time_offset; + } + } + for (LogEntry &entry : route_data.logs) { + entry.boot_time = entry.mono_time; + entry.mono_time -= time_offset; + } + for (CanMessageData &message : can_messages) { + for (CanFrameSample &sample : message.samples) { + sample.mono_time -= time_offset; + } + } + for (TimelineEntry &entry : timeline) { + entry.start_time -= time_offset; + entry.end_time -= time_offset; + } + route_data.x_max -= time_offset; + route_data.x_min = 0.0; + } + + std::sort(timeline.begin(), timeline.end(), [](const TimelineEntry &a, const TimelineEntry &b) { + return a.start_time < b.start_time; + }); + std::vector merged_timeline; + merged_timeline.reserve(timeline.size()); + for (TimelineEntry &entry : timeline) { + if (!merged_timeline.empty() && merged_timeline.back().type == entry.type) { + merged_timeline.back().end_time = std::max(merged_timeline.back().end_time, entry.end_time); + continue; + } + merged_timeline.push_back(std::move(entry)); + } + route_data.timeline = std::move(merged_timeline); + std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { + return std::make_tuple(a.id.service, a.id.bus, a.id.address) + < std::make_tuple(b.id.service, b.id.bus, b.id.address); + }); + route_data.can_messages = std::move(can_messages); + + route_data.enum_info = std::move(enum_info); + route_data.car_fingerprint = std::move(car_fingerprint); + route_data.dbc_name = std::move(dbc_name); + rebuild_gps_trace(&route_data); + route_data.roots = collect_route_roots_for_paths(route_data.paths); + return route_data; +} + +const RouteSeries *find_route_series(const RouteData &route_data, std::string_view path) { + auto it = std::lower_bound(route_data.series.begin(), route_data.series.end(), path, + [](const RouteSeries &series, std::string_view target) { + return series.path < target; + }); + if (it == route_data.series.end() || it->path != path) return nullptr; + return &(*it); +} + +std::optional sample_series_at_time(const RouteSeries &series, double tm) { + if (series.times.empty() || series.times.size() != series.values.size()) { + return std::nullopt; + } + if (tm <= series.times.front()) { + return series.values.front(); + } + if (tm >= series.times.back()) { + return series.values.back(); + } + auto upper = std::lower_bound(series.times.begin(), series.times.end(), tm); + if (upper == series.times.begin()) { + return series.values.front(); + } + if (upper == series.times.end()) { + return series.values.back(); + } + const size_t upper_index = static_cast(std::distance(series.times.begin(), upper)); + const size_t lower_index = upper_index - 1; + const double t0 = series.times[lower_index]; + const double t1 = series.times[upper_index]; + const double v0 = series.values[lower_index]; + const double v1 = series.values[upper_index]; + if (t1 <= t0) { + return v0; + } + const double alpha = (tm - t0) / (t1 - t0); + return v0 + (v1 - v0) * alpha; +} + +} // namespace + +void rebuild_gps_trace(RouteData *route_data) { + route_data->gps_trace = {}; + const RouteSeries *latitude = find_route_series(*route_data, "/gpsLocationExternal/latitude"); + const RouteSeries *longitude = find_route_series(*route_data, "/gpsLocationExternal/longitude"); + const RouteSeries *has_fix = find_route_series(*route_data, "/gpsLocationExternal/hasFix"); + if (latitude == nullptr || longitude == nullptr || has_fix == nullptr) { + return; + } + + const RouteSeries *bearing = find_route_series(*route_data, "/gpsLocationExternal/bearingDeg"); + size_t count = std::min({latitude->times.size(), latitude->values.size(), + longitude->times.size(), longitude->values.size(), + has_fix->times.size(), has_fix->values.size()}); + if (count == 0) { + return; + } + + bool found = false; + route_data->gps_trace.points.reserve(count); + for (size_t i = 0; i < count; ++i) { + if (has_fix->values[i] < 0.5) { + continue; + } + const double lat = latitude->values[i]; + const double lon = longitude->values[i]; + if (!std::isfinite(lat) || !std::isfinite(lon)) { + continue; + } + const double tm = latitude->times[i]; + const float bearing_value = bearing != nullptr + ? static_cast(sample_series_at_time(*bearing, tm).value_or(0.0)) + : 0.0f; + route_data->gps_trace.points.push_back(GpsPoint{ + .time = tm, + .lat = lat, + .lon = lon, + .bearing = bearing_value, + .type = timeline_type_at_time(route_data->timeline, tm), + }); + if (!found) { + route_data->gps_trace.min_lat = route_data->gps_trace.max_lat = lat; + route_data->gps_trace.min_lon = route_data->gps_trace.max_lon = lon; + found = true; + } else { + route_data->gps_trace.min_lat = std::min(route_data->gps_trace.min_lat, lat); + route_data->gps_trace.max_lat = std::max(route_data->gps_trace.max_lat, lat); + route_data->gps_trace.min_lon = std::min(route_data->gps_trace.min_lon, lon); + route_data->gps_trace.max_lon = std::max(route_data->gps_trace.max_lon, lon); + } + } + if (!found) { + route_data->gps_trace = {}; + } +} + +namespace { + +void build_camera_index(const std::map &segments, + const RouteData &route_data, + std::string SegmentLogs::*file_member, + std::string_view index_name, + CameraFeedIndex *out) { + *out = {}; + out->segment_files.reserve(segments.size()); + + std::unordered_set available_segments; + available_segments.reserve(segments.size()); + for (const auto &[segment_number, segment] : segments) { + const std::string &path = segment.*file_member; + if (path.empty()) continue; + out->segment_files.push_back(CameraSegmentFile{ + .segment = segment_number, + .path = path, + }); + available_segments.insert(segment_number); + } + if (out->segment_files.empty()) { + return; + } + + const std::string prefix = "/" + std::string(index_name); + const RouteSeries *segment_numbers = find_route_series(route_data, prefix + "/segmentNum"); + const RouteSeries *decode_indices = find_route_series(route_data, prefix + "/segmentId"); + if (decode_indices == nullptr) { + decode_indices = find_route_series(route_data, prefix + "/segmentIdEncode"); + } + const RouteSeries *frame_ids = find_route_series(route_data, prefix + "/frameId"); + if (segment_numbers == nullptr || decode_indices == nullptr) { + return; + } + + size_t count = std::min(segment_numbers->times.size(), segment_numbers->values.size()); + count = std::min(count, decode_indices->values.size()); + if (frame_ids != nullptr) { + count = std::min(count, frame_ids->values.size()); + } + out->entries.reserve(count); + for (size_t i = 0; i < count; ++i) { + const int segment_number = static_cast(std::llround(segment_numbers->values[i])); + if (available_segments.find(segment_number) == available_segments.end()) { + continue; + } + const int decode_index = static_cast(std::llround(decode_indices->values[i])); + const uint32_t frame_id = frame_ids != nullptr + ? static_cast(std::llround(frame_ids->values[i])) + : static_cast(std::max(0, decode_index)); + out->entries.push_back(CameraFrameIndexEntry{ + .timestamp = segment_numbers->times[i], + .segment = segment_number, + .decode_index = decode_index, + .frame_id = frame_id, + }); + } + + std::sort(out->entries.begin(), out->entries.end(), + [](const CameraFrameIndexEntry &a, const CameraFrameIndexEntry &b) { + return a.timestamp < b.timestamp; + }); +} + +size_t load_worker_budget() { + size_t worker_count = std::thread::hardware_concurrency(); + if (worker_count == 0) { + worker_count = 1; + } + if (const char *raw = std::getenv("JOTP_LOAD_WORKERS"); raw != nullptr && std::strlen(raw) > 0) { + char *end = nullptr; + const unsigned long parsed = std::strtoul(raw, &end, 10); + if (end != nullptr && *end == '\0' && parsed > 0) { + worker_count = static_cast(parsed); + } + } + return std::max(1, worker_count); +} + +size_t segment_worker_count(size_t segment_count, size_t worker_budget) { + return std::max(1, std::min(worker_budget, segment_count)); +} + +size_t extract_chunk_count(size_t event_count, size_t worker_budget, size_t segment_workers) { + if (event_count < 4096) return 1; + const size_t per_segment_budget = std::max(1, worker_budget / std::max(1, segment_workers)); + const size_t chunk_target = std::max(1, (event_count + 14999) / 15000); + return std::max(1, std::min({per_segment_budget, chunk_target, static_cast(8)})); +} + +SeriesAccumulator extract_segment_series(const std::vector &events, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + size_t worker_budget, + size_t segment_workers) { + const size_t chunk_count = extract_chunk_count(events.size(), worker_budget, segment_workers); + if (chunk_count <= 1 || events.empty()) { + SeriesAccumulator series = make_series_accumulator(schema); + append_events_fast_range(events, 0, events.size(), schema, can_dbc, skip_raw_can, &series); + return series; + } + + const size_t events_per_chunk = (events.size() + chunk_count - 1) / chunk_count; + std::vector chunk_results; + chunk_results.reserve(chunk_count); + for (size_t i = 0; i < chunk_count; ++i) { + chunk_results.push_back(make_series_accumulator(schema)); + } + + std::vector workers; + workers.reserve(chunk_count > 0 ? chunk_count - 1 : 0); + for (size_t chunk = 1; chunk < chunk_count; ++chunk) { + workers.emplace_back([&, chunk]() { + const size_t begin = chunk * events_per_chunk; + const size_t end = std::min(events.size(), begin + events_per_chunk); + append_events_fast_range(events, begin, end, schema, can_dbc, skip_raw_can, &chunk_results[chunk]); + }); + } + append_events_fast_range(events, 0, std::min(events.size(), events_per_chunk), schema, can_dbc, skip_raw_can, &chunk_results[0]); + for (std::thread &worker : workers) { + worker.join(); + } + + SeriesAccumulator merged = make_series_accumulator(schema); + for (SeriesAccumulator &chunk : chunk_results) { + merge_series_accumulator(&merged, &chunk); + } + return merged; +} + +LoadedRouteArtifacts load_route_series_parallel( + const std::map &segments, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + LogSelector selector, + bool skip_raw_can, + LoadStats *stats) { + struct SegmentResult { + SeriesAccumulator series; + std::vector logs; + std::vector timeline; + }; + + const std::vector> segment_list(segments.begin(), segments.end()); + std::vector results; + results.reserve(segment_list.size()); + for (size_t i = 0; i < segment_list.size(); ++i) { + results.emplace_back(SegmentResult{make_series_accumulator(schema)}); + } + std::atomic next_segment{0}; + std::mutex error_mutex; + std::string first_error; + const size_t worker_budget = static_cast(stats->num_workers); + const size_t segment_workers = segment_worker_count(segment_list.size(), worker_budget); + + auto worker = [&]() { + while (true) { + const size_t index = next_segment.fetch_add(1); + if (index >= segment_list.size()) { + return; + } + + const auto &[segment_number, segment] = segment_list[index]; + const std::string &log_path = selected_log_path(segment, selector); + LoadStats::SegmentStats &segment_stats = stats->segments[index]; + segment_stats.segment_number = segment_number; + segment_stats.log_path = log_path; + if (log_path.empty()) { + segment_stats.failed = true; + std::lock_guard lock(error_mutex); + if (first_error.empty()) { + first_error = "Missing log path for segment " + std::to_string(segment_number); + } + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + continue; + } + + LogReader reader; + if (!reader.load(log_path, nullptr, true)) { + segment_stats.failed = true; + std::lock_guard lock(error_mutex); + if (first_error.empty()) { + first_error = "Failed to load log segment: " + log_path; + } + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + continue; + } + + segment_stats.download_seconds = reader.download_seconds(); + segment_stats.decompress_seconds = reader.decompress_seconds(); + segment_stats.parse_seconds = reader.parse_seconds(); + segment_stats.compressed_bytes = reader.compressed_size(); + segment_stats.decompressed_bytes = reader.decompressed_size(); + stats->bytes_downloaded.fetch_add(reader.compressed_size()); + stats->segments_downloaded.fetch_add(1); + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + + const auto extract_start = LoadStats::Clock::now(); + results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); + results[index].logs = extract_segment_logs(reader.events); + results[index].timeline = extract_segment_timeline(reader.events); + segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); + segment_stats.event_count = reader.events.size(); + segment_stats.series_count = populated_series_count(results[index].series); + stats->segments_parsed.fetch_add(1); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + } + }; + + std::vector workers; + workers.reserve(segment_workers); + for (size_t i = 0; i < segment_workers; ++i) { + workers.emplace_back(worker); + } + for (std::thread &thread : workers) { + thread.join(); + } + + if (!first_error.empty()) throw std::runtime_error(first_error); + + stats->merge_start = LoadStats::Clock::now(); + SeriesAccumulator merged = make_series_accumulator(schema); + for (size_t i = 0; i < results.size(); ++i) { + merge_series_accumulator(&merged, &results[i].series); + } + std::vector logs; + std::vector timeline; + for (SegmentResult &result : results) { + if (!result.logs.empty()) { + logs.insert(logs.end(), + std::make_move_iterator(result.logs.begin()), + std::make_move_iterator(result.logs.end())); + } + if (!result.timeline.empty()) { + timeline.insert(timeline.end(), + std::make_move_iterator(result.timeline.begin()), + std::make_move_iterator(result.timeline.end())); + } + } + LoadedRouteArtifacts artifacts; + artifacts.series = collect_series(std::move(merged)); + artifacts.can_messages = std::move(merged.can_messages); + artifacts.logs = std::move(logs); + artifacts.timeline = std::move(timeline); + artifacts.enum_info = std::move(merged.enum_info); + stats->merge_end = LoadStats::Clock::now(); + return artifacts; +} + +std::vector collect_layout_roots(const SketchLayout &layout) { + std::vector roots; + for (const auto &tab : layout.tabs) { + for (const auto &pane : tab.panes) { + for (const auto &curve : pane.curves) { + std::string root = "custom"; + if (is_absolute_curve(curve.name)) { + const size_t slash = curve.name.find('/', 1); + root = curve.name.substr(1, slash == std::string::npos ? std::string::npos : slash - 1); + } + if (std::find(roots.begin(), roots.end(), root) == roots.end()) { + roots.push_back(root); + } + } + } + } + if (roots.empty()) { + roots.push_back("layout"); + } + return roots; +} + +} // namespace + +std::vector collect_route_roots_for_paths(const std::vector &paths) { + std::vector roots; + for (const std::string &path : paths) { + if (!is_absolute_curve(path)) continue; + const size_t slash = path.find('/', 1); + const std::string root = path.substr(1, slash == std::string::npos ? std::string::npos : slash - 1); + if (!root.empty() && std::find(roots.begin(), roots.end(), root) == roots.end()) { + roots.push_back(root); + } + } + std::sort(roots.begin(), roots.end()); + return roots; +} + +struct StreamAccumulator::Impl { + const SchemaIndex &schema = SchemaIndex::instance(); + SeriesAccumulator series = make_series_accumulator(schema); + std::vector logs; + std::vector timeline; + std::string last_alert_key; + std::string manual_dbc_name; + std::string detected_dbc_name; + std::string car_fingerprint; + std::optional can_dbc; + std::optional time_offset; + + void refresh_dbc() { + const std::string next_dbc = !manual_dbc_name.empty() ? manual_dbc_name : detect_dbc_for_fingerprint(car_fingerprint); + if (next_dbc == detected_dbc_name) { + return; + } + detected_dbc_name = next_dbc; + can_dbc.reset(); + if (!detected_dbc_name.empty()) { + can_dbc.emplace(resolve_dbc_path(detected_dbc_name)); + } + } +}; + +StreamAccumulator::StreamAccumulator(const std::string &dbc_name, std::optional time_offset) + : impl_(std::make_unique()) { + impl_->manual_dbc_name = dbc_name; + impl_->time_offset = time_offset; + impl_->refresh_dbc(); +} + +StreamAccumulator::~StreamAccumulator() = default; + +void StreamAccumulator::setDbcName(const std::string &dbc_name) { + impl_->manual_dbc_name = dbc_name; + impl_->refresh_dbc(); +} + +void StreamAccumulator::appendEvent(cereal::Event::Which which, kj::ArrayPtr data) { + capnp::FlatArrayMessageReader event_reader(data); + const cereal::Event::Reader event = event_reader.getRoot(); + const double boot_time = static_cast(event.getLogMonoTime()) / 1.0e9; + if (!impl_->time_offset.has_value()) { + impl_->time_offset = boot_time; + } + if (which == cereal::Event::Which::CAR_PARAMS) { + const std::string fingerprint = event.getCarParams().getCarFingerprint().cStr(); + if (!fingerprint.empty() && fingerprint != impl_->car_fingerprint) { + impl_->car_fingerprint = fingerprint; + impl_->refresh_dbc(); + } + } + + append_event_fast(which, + -1, + data, + impl_->schema, + impl_->can_dbc ? &*impl_->can_dbc : nullptr, + true, + *impl_->time_offset, + &impl_->series); + append_log_event(which, event, *impl_->time_offset, &impl_->logs, &impl_->last_alert_key); + if (which == cereal::Event::Which::SELFDRIVE_STATE) { + const auto sd = event.getSelfdriveState(); + append_timeline_entry(&impl_->timeline, boot_time - *impl_->time_offset, + alert_status_to_timeline_type(sd.getAlertStatus(), sd.getEnabled())); + } +} + +void StreamAccumulator::appendCanFrames(CanServiceKind service, const std::vector &frames) { + if (frames.empty()) { + return; + } + if (!impl_->time_offset.has_value()) { + impl_->time_offset = frames.front().mono_time; + } + for (const LiveCanFrame &frame : frames) { + append_live_can_frame(service, + frame, + *impl_->time_offset, + impl_->can_dbc ? &*impl_->can_dbc : nullptr, + &impl_->series); + } +} + +StreamExtractBatch StreamAccumulator::takeBatch() { + StreamExtractBatch batch; + batch.car_fingerprint = impl_->car_fingerprint; + batch.dbc_name = impl_->detected_dbc_name; + if (impl_->time_offset.has_value()) { + batch.has_time_offset = true; + batch.time_offset = *impl_->time_offset; + } + if (impl_->logs.empty() && impl_->timeline.empty() + && populated_series_count(impl_->series) == 0 + && impl_->series.enum_info.empty() + && impl_->series.can_messages.empty()) { + return batch; + } + + SeriesAccumulator emitted = std::move(impl_->series); + batch.can_messages = std::move(emitted.can_messages); + batch.enum_info = std::move(emitted.enum_info); + batch.series = collect_series(std::move(emitted)); + batch.logs = std::move(impl_->logs); + batch.timeline = std::move(impl_->timeline); + impl_->series = make_series_accumulator(impl_->schema); + impl_->logs.clear(); + impl_->timeline.clear(); + return batch; +} + +const std::string &StreamAccumulator::carFingerprint() const { + return impl_->car_fingerprint; +} + +const std::string &StreamAccumulator::dbc_name() const { + return impl_->detected_dbc_name; +} + +std::optional StreamAccumulator::timeOffset() const { + return impl_->time_offset; +} + +SketchLayout load_sketch_layout(const fs::path &layout_path) { + SketchLayout layout = parse_layout(layout_path); + layout.roots = collect_layout_roots(layout); + return layout; +} + +RouteData load_route_data(const std::string &route_name, + const std::string &data_dir, + const std::string &dbc_name, + const RouteLoadProgressCallback &progress) { + if (route_name.empty()) return RouteData{}; + + const RouteSelection route = parse_route_selection(route_name); + if (route.canonical_name.empty() || (data_dir.empty() && route.dongle_id.empty())) { + throw std::runtime_error("Invalid route format: " + route_name); + } + LoadStats stats(progress); + stats.load_start = LoadStats::Clock::now(); + std::map segments = data_dir.empty() + ? load_segments_from_server(route) + : load_segments_from_local(route, data_dir); + segments = trim_segments(std::move(segments), route); + if (segments.empty()) throw std::runtime_error("No log segments found for " + route_name); + stats.resolve_end = LoadStats::Clock::now(); + stats.segment_count = segments.size(); + stats.total_segments.store(segments.size()); + stats.num_workers = static_cast(load_worker_budget()); + stats.segments.resize(segments.size()); + stats.publish(RouteLoadStage::Resolving, 0, {}); + + const RouteMetadata metadata = detect_route_metadata(segments, route.selector); + const std::string resolved_dbc = !dbc_name.empty() ? dbc_name : detect_dbc_for_fingerprint(metadata.car_fingerprint); + const std::optional can_dbc = resolved_dbc.empty() + ? std::nullopt + : std::optional(std::in_place, resolve_dbc_path(resolved_dbc)); + + const SchemaIndex &schema = SchemaIndex::instance(); + LoadedRouteArtifacts artifacts = load_route_series_parallel(segments, schema, can_dbc ? &*can_dbc : nullptr, + route.selector, !resolved_dbc.empty(), &stats); + RouteData route_data = build_route_data(std::move(artifacts.series), + std::move(artifacts.can_messages), + std::move(artifacts.logs), + std::move(artifacts.timeline), + std::move(artifacts.enum_info), + metadata.car_fingerprint, + resolved_dbc); + route_data.route_id = make_route_identifier(route, segments); + build_camera_index(segments, route_data, &SegmentLogs::fcamera, "roadEncodeIdx", &route_data.road_camera); + build_camera_index(segments, route_data, &SegmentLogs::dcamera, "driverEncodeIdx", &route_data.driver_camera); + build_camera_index(segments, route_data, &SegmentLogs::ecamera, "wideRoadEncodeIdx", &route_data.wide_road_camera); + build_camera_index(segments, route_data, &SegmentLogs::qcamera, "qRoadEncodeIdx", &route_data.qroad_camera); + stats.load_end = LoadStats::Clock::now(); + stats.publish(RouteLoadStage::Finished, segments.size(), {}); + stats.print_summary(route_data.series.size()); + return route_data; +} + +RouteIdentifier parse_route_identifier(std::string_view route_name) { + return make_route_identifier(parse_route_selection(std::string(route_name)), {}); +} + +std::vector available_dbc_names() { + return available_dbc_names_impl(); +} + +std::optional load_dbc_by_name(const std::string &dbc_name) { + if (dbc_name.empty()) { + return std::nullopt; + } + try { + return std::optional(std::in_place, resolve_dbc_path(dbc_name)); + } catch (...) { + return std::nullopt; + } +} + +std::vector decode_can_messages(const std::vector &can_messages, + const std::string &dbc_name, + std::unordered_map *enum_info) { + if (enum_info != nullptr) { + enum_info->clear(); + } + const std::optional can_dbc = load_dbc_by_name(dbc_name); + if (!can_dbc.has_value()) { + return {}; + } + + SeriesAccumulator series; + for (const CanMessageData &message : can_messages) { + const char *service_name = message.id.service == CanServiceKind::Can ? "can" : "sendcan"; + for (const CanFrameSample &sample : message.samples) { + decode_can_frame(&*can_dbc, + service_name, + message.id.bus, + message.id.address, + reinterpret_cast(sample.data.data()), + sample.data.size(), + sample.mono_time, + &series); + } + } + if (enum_info != nullptr) { + *enum_info = std::move(series.enum_info); + } + return collect_series(std::move(series)); +} diff --git a/tools/jotpluggler/stream.cc b/tools/jotpluggler/stream.cc new file mode 100644 index 000000000..fcfa6585b --- /dev/null +++ b/tools/jotpluggler/stream.cc @@ -0,0 +1,207 @@ +#include "tools/jotpluggler/internal.h" + +#include + +template +std::optional stream_batch_extreme_time(const StreamExtractBatch &batch, + Cmp cmp, + SeriesAccessor series_time, + LogAccessor log_time_fn) { + std::optional result; + for (const RouteSeries &series : batch.series) { + if (!series.times.empty()) { + const double t = series_time(series); + result = result.has_value() ? cmp(*result, t) : t; + } + } + if (!batch.logs.empty()) { + const double t = log_time_fn(batch); + result = result.has_value() ? cmp(*result, t) : t; + } + if (!batch.timeline.empty()) { + const double t = cmp(batch.timeline.front().start_time, batch.timeline.back().end_time); + result = result.has_value() ? cmp(*result, t) : t; + } + for (const CanMessageData &message : batch.can_messages) { + if (!message.samples.empty()) { + const double t = cmp(message.samples.front().mono_time, message.samples.back().mono_time); + result = result.has_value() ? cmp(*result, t) : t; + } + } + return result; +} + +std::optional earliest_stream_batch_time(const StreamExtractBatch &batch) { + return stream_batch_extreme_time(batch, + [](double a, double b) { return std::min(a, b); }, + [](const RouteSeries &s) { return s.times.front(); }, + [](const StreamExtractBatch &b) { return b.logs.front().mono_time; }); +} + +std::optional latest_stream_batch_time(const StreamExtractBatch &batch) { + return stream_batch_extreme_time(batch, + [](double a, double b) { return std::max(a, b); }, + [](const RouteSeries &s) { return s.times.back(); }, + [](const StreamExtractBatch &b) { return b.logs.back().mono_time; }); +} + +bool layout_has_custom_curves(const SketchLayout &layout) { + for (const WorkspaceTab &tab : layout.tabs) { + for (const Pane &pane : tab.panes) { + for (const Curve &curve : pane.curves) { + if (curve.custom_python.has_value()) return true; + } + } + } + return false; +} + +void append_stream_timeline_entries(std::vector *timeline, std::vector entries) { + for (TimelineEntry &entry : entries) { + if (!timeline->empty() && timeline->back().type == entry.type) { + timeline->back().end_time = std::max(timeline->back().end_time, entry.end_time); + } else { + timeline->push_back(std::move(entry)); + } + } +} + +bool can_message_less(const CanMessageData &a, const CanMessageData &b) { + return std::make_tuple(a.id.service, a.id.bus, a.id.address) + < std::make_tuple(b.id.service, b.id.bus, b.id.address); +} + +void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) { + if (batch.has_time_offset) { + session->stream_time_offset = batch.time_offset; + } + if (!batch.car_fingerprint.empty()) { + session->route_data.car_fingerprint = batch.car_fingerprint; + } + if (!batch.dbc_name.empty()) { + session->route_data.dbc_name = batch.dbc_name; + } + if (!batch.enum_info.empty()) { + for (auto &[path, info] : batch.enum_info) { + session->route_data.enum_info[path] = std::move(info); + } + } + + bool new_paths = false; + std::vector new_series; + std::vector touched_paths; + touched_paths.reserve(batch.series.size()); + for (RouteSeries &incoming : batch.series) { + touched_paths.push_back(incoming.path); + auto existing_it = session->series_by_path.find(incoming.path); + if (existing_it == session->series_by_path.end()) { + new_series.push_back(std::move(incoming)); + new_paths = true; + continue; + } + RouteSeries &existing = *existing_it->second; + existing.times.insert(existing.times.end(), incoming.times.begin(), incoming.times.end()); + existing.values.insert(existing.values.end(), incoming.values.begin(), incoming.values.end()); + } + for (RouteSeries &series : new_series) { + session->route_data.paths.push_back(series.path); + session->route_data.series.push_back(std::move(series)); + } + + if (!batch.logs.empty()) { + std::sort(batch.logs.begin(), batch.logs.end(), [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + const size_t old_size = session->route_data.logs.size(); + session->route_data.logs.insert(session->route_data.logs.end(), + std::make_move_iterator(batch.logs.begin()), + std::make_move_iterator(batch.logs.end())); + if (old_size > 0 && session->route_data.logs.size() > old_size + && session->route_data.logs[old_size - 1].mono_time > session->route_data.logs[old_size].mono_time) { + std::inplace_merge(session->route_data.logs.begin(), + session->route_data.logs.begin() + static_cast(old_size), + session->route_data.logs.end(), + [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + } + } + if (!batch.timeline.empty()) { + append_stream_timeline_entries(&session->route_data.timeline, std::move(batch.timeline)); + } + + for (CanMessageData &incoming : batch.can_messages) { + auto it = std::lower_bound(session->route_data.can_messages.begin(), + session->route_data.can_messages.end(), + incoming, + can_message_less); + if (it == session->route_data.can_messages.end() + || can_message_less(incoming, *it) + || can_message_less(*it, incoming)) { + session->route_data.can_messages.insert(it, std::move(incoming)); + } else { + it->samples.insert(it->samples.end(), + std::make_move_iterator(incoming.samples.begin()), + std::make_move_iterator(incoming.samples.end())); + } + } + + if (new_paths) { + const size_t old_path_count = session->route_data.paths.size() - new_series.size(); + std::sort(session->route_data.paths.begin() + static_cast(old_path_count), session->route_data.paths.end()); + std::inplace_merge(session->route_data.paths.begin(), + session->route_data.paths.begin() + static_cast(old_path_count), + session->route_data.paths.end()); + const size_t old_series_count = session->route_data.series.size() - new_series.size(); + auto series_cmp = [](const RouteSeries &a, const RouteSeries &b) { return a.path < b.path; }; + std::sort(session->route_data.series.begin() + static_cast(old_series_count), + session->route_data.series.end(), series_cmp); + std::inplace_merge(session->route_data.series.begin(), + session->route_data.series.begin() + static_cast(old_series_count), + session->route_data.series.end(), series_cmp); + session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } else { + for (const std::string &path : touched_paths) { + auto series_it = session->series_by_path.find(path); + if (series_it == session->series_by_path.end() || series_it->second == nullptr) continue; + const bool enum_like = session->route_data.enum_info.find(path) != session->route_data.enum_info.end(); + session->route_data.series_formats[path] = compute_series_format(series_it->second->values, enum_like); + } + } + const std::optional earliest_time = earliest_stream_batch_time(batch); + const std::optional latest_time = latest_stream_batch_time(batch); + if (earliest_time.has_value() && latest_time.has_value()) { + if (!session->route_data.has_time_range) { + session->route_data.x_min = *earliest_time; + session->route_data.x_max = *latest_time; + } else { + session->route_data.x_min = std::min(session->route_data.x_min, *earliest_time); + session->route_data.x_max = std::max(session->route_data.x_max, *latest_time); + } + session->route_data.has_time_range = true; + } + + if (new_paths + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/latitude") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/longitude") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/hasFix") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/bearingDeg") != touched_paths.end()) { + rebuild_gps_trace(&session->route_data); + } + + if (latest_time.has_value() && layout_has_custom_curves(session->layout) + && *latest_time >= session->next_stream_custom_refresh_time) { + refresh_all_custom_curves(session, state); + session->next_stream_custom_refresh_time = *latest_time + 0.1; + } + if (state->follow_latest || !state->has_tracker_time) { + state->tracker_time = session->route_data.x_max; + state->has_tracker_time = session->route_data.has_time_range; + } + if (!state->has_shared_range) { + reset_shared_range(state, *session); + } +} diff --git a/tools/jotpluggler/util.cc b/tools/jotpluggler/util.cc new file mode 100644 index 000000000..5c20e795f --- /dev/null +++ b/tools/jotpluggler/util.cc @@ -0,0 +1,59 @@ +#include "tools/jotpluggler/util.h" + +#include +#include +#include +#include + +std::string read_file_or_throw(const std::filesystem::path &path) { + const std::string contents = util::read_file(path.string()); + if (!contents.empty() || std::filesystem::exists(path)) { + return contents; + } + throw std::runtime_error("Failed to read " + path.string()); +} + +void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size) { + ensure_parent_dir(path); + const std::string path_string = path.string(); + const void *bytes = size == 0 ? static_cast("") : data; + if (util::write_file(path_string.c_str(), bytes, size, O_WRONLY | O_CREAT | O_TRUNC) != 0) { + throw std::runtime_error("Failed to write " + path_string); + } +} + +void write_file_or_throw(const std::filesystem::path &path, std::string_view contents) { + write_file_or_throw(path, contents.data(), contents.size()); +} + +void run_system_or_throw(const std::string &command, std::string_view action) { + const int ret = std::system(command.c_str()); + if (ret != 0) { + throw std::runtime_error(util::string_format("%.*s failed with exit code %d", + static_cast(action.size()), action.data(), ret)); + } +} + +CommandResult run_process_capture_output(const std::vector &args) { + std::string command; + for (const std::string &arg : args) { + if (!command.empty()) command += ' '; + command += shell_quote(arg); + } + command += " 2>&1"; + + FILE *pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) { + throw std::runtime_error("popen() failed"); + } + + CommandResult result; + std::array buf = {}; + while (fgets(buf.data(), static_cast(buf.size()), pipe) != nullptr) { + result.output += buf.data(); + } + + const int status = pclose(pipe); + result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1; + return result; +} diff --git a/tools/jotpluggler/util.h b/tools/jotpluggler/util.h new file mode 100644 index 000000000..ea77a236f --- /dev/null +++ b/tools/jotpluggler/util.h @@ -0,0 +1,103 @@ +#pragma once + +#include "common/util.h" +#include "imgui.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +inline ImVec4 color_rgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(static_cast(r) / 255.0f, + static_cast(g) / 255.0f, + static_cast(b) / 255.0f, + alpha); +} + +inline ImVec4 color_rgb(const std::array &color, float alpha = 1.0f) { + return color_rgb(color[0], color[1], color[2], alpha); +} + +inline std::string lowercase_copy(std::string_view value) { + std::string out(value); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return out; +} + +inline int imgui_resize_callback(ImGuiInputTextCallbackData *data) { + if (data->EventFlag != ImGuiInputTextFlags_CallbackResize || data->UserData == nullptr) return 0; + auto *text = static_cast(data->UserData); + text->resize(static_cast(data->BufTextLen)); + data->Buf = text->data(); + return 0; +} + +inline bool input_text_string(const char *label, + std::string *text, + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputText(label, text->data(), text->capacity() + 1, + flags, imgui_resize_callback, text); +} + +inline bool input_text_with_hint_string(const char *label, + const char *hint, + std::string *text, + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputTextWithHint(label, hint, text->data(), text->capacity() + 1, + flags, imgui_resize_callback, text); +} + +inline bool input_text_multiline_string(const char *label, + std::string *text, + const ImVec2 &size = ImVec2(0.0f, 0.0f), + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputTextMultiline(label, text->data(), text->capacity() + 1, + size, flags, imgui_resize_callback, text); +} + +inline bool is_local_stream_address(std::string_view address) { + return address.empty() || address == "127.0.0.1" || address == "localhost"; +} + +inline void ensure_parent_dir(const std::filesystem::path &path) { + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path()); + } +} + +inline std::string shell_quote(std::string_view value) { + std::string quoted; + quoted.reserve(value.size() + 8); + quoted.push_back('\''); + for (char c : value) { + if (c == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(c); + } + } + quoted.push_back('\''); + return quoted; +} + +struct CommandResult { + int exit_code = 0; + std::string output; +}; + +std::string read_file_or_throw(const std::filesystem::path &path); +void write_file_or_throw(const std::filesystem::path &path, std::string_view contents); +void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size); +void run_system_or_throw(const std::string &command, std::string_view action); +CommandResult run_process_capture_output(const std::vector &args); diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index 997a4bc00..aba67bcdf 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -1,31 +1,68 @@ #include "tools/replay/logreader.h" #include +#include #include #include "tools/replay/filereader.h" +#include "tools/replay/py_downloader.h" #include "tools/replay/util.h" #include "common/util.h" -bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache) { +bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache, + const ProgressCallback &progress) { + using Clock = std::chrono::steady_clock; + compressed_size_ = 0; + decompressed_size_ = 0; + download_seconds_ = 0.0; + decompress_seconds_ = 0.0; + parse_seconds_ = 0.0; + + if (progress) { + installDownloadProgressHandler([progress](uint64_t cur, uint64_t total, bool success) { + if (success) { + progress(ProgressStage::Downloading, cur, total); + } + }); + } + const auto download_start = Clock::now(); std::string data = FileReader(local_cache).read(url, abort); + const auto download_end = Clock::now(); + if (progress) { + installDownloadProgressHandler(nullptr); + } + compressed_size_ = data.size(); + download_seconds_ = std::chrono::duration(download_end - download_start).count(); if (!data.empty()) { + const auto decompress_start = Clock::now(); if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) { data = decompressBZ2(data, abort); } else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) { data = decompressZST(data, abort); } + const auto decompress_end = Clock::now(); + decompress_seconds_ = std::chrono::duration(decompress_end - decompress_start).count(); } + decompressed_size_ = data.size(); - bool success = !data.empty() && load(data.data(), data.size(), abort); + bool success = !data.empty() && load(data.data(), data.size(), abort, progress); if (filters_.empty()) raw_ = std::move(data); return success; } -bool LogReader::load(const char *data, size_t size, std::atomic *abort) { +bool LogReader::load(const char *data, size_t size, std::atomic *abort, + const ProgressCallback &progress) { + using Clock = std::chrono::steady_clock; + const auto parse_start = Clock::now(); try { events.reserve(65000); kj::ArrayPtr words((const capnp::word *)data, size / sizeof(capnp::word)); + const uint64_t total_bytes = size; + const uint64_t report_step = std::max(1, total_bytes / 200); + uint64_t last_reported = 0; + if (progress) { + progress(ProgressStage::Parsing, 0, total_bytes); + } while (words.size() > 0 && !(abort && *abort)) { capnp::FlatArrayMessageReader reader(words); auto event = reader.getRoot(); @@ -56,15 +93,30 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort) { events.emplace_back(which, sof ? sof : mono_time, event_data, idx.getSegmentNum()); } } + + if (progress) { + const uint64_t current_bytes = + total_bytes - static_cast(words.size() * sizeof(capnp::word)); + if (current_bytes >= total_bytes || current_bytes - last_reported >= report_step) { + progress(ProgressStage::Parsing, current_bytes, total_bytes); + last_reported = current_bytes; + } + } } } catch (const kj::Exception &e) { rWarning("Failed to parse log : %s.\nRetrieved %zu events from corrupt log", e.getDescription().cStr(), events.size()); } + if (progress) { + progress(ProgressStage::Parsing, size, size); + } + if (requires_migration) { migrateOldEvents(); } + parse_seconds_ = std::chrono::duration(Clock::now() - parse_start).count(); + if (!events.empty() && !(abort && *abort)) { events.shrink_to_fit(); std::sort(events.begin(), events.end()); diff --git a/tools/replay/logreader.h b/tools/replay/logreader.h index 9219878ac..fe11ab0f7 100644 --- a/tools/replay/logreader.h +++ b/tools/replay/logreader.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -26,12 +28,26 @@ public: class LogReader { public: + enum class ProgressStage { + Downloading, + Parsing, + }; + + using ProgressCallback = std::function; + LogReader(const std::vector &filters = {}) { filters_ = filters; } bool load(const std::string &url, std::atomic *abort = nullptr, - bool local_cache = false); - bool load(const char *data, size_t size, std::atomic *abort = nullptr); + bool local_cache = false, const ProgressCallback &progress = {}); + bool load(const char *data, size_t size, std::atomic *abort = nullptr, + const ProgressCallback &progress = {}); std::vector events; + uint64_t compressed_size() const { return compressed_size_; } + uint64_t decompressed_size() const { return decompressed_size_; } + double download_seconds() const { return download_seconds_; } + double decompress_seconds() const { return decompress_seconds_; } + double parse_seconds() const { return parse_seconds_; } + private: void migrateOldEvents(); @@ -39,4 +55,9 @@ private: bool requires_migration = true; std::vector filters_; MonotonicBuffer buffer_{1024 * 1024}; + uint64_t compressed_size_ = 0; + uint64_t decompressed_size_ = 0; + double download_seconds_ = 0.0; + double decompress_seconds_ = 0.0; + double parse_seconds_ = 0.0; }; diff --git a/tools/replay/py_downloader.cc b/tools/replay/py_downloader.cc index efaf3c93a..5063d6947 100644 --- a/tools/replay/py_downloader.cc +++ b/tools/replay/py_downloader.cc @@ -149,11 +149,16 @@ std::string runPython(const std::vector &args, std::atomic *a int status; waitpid(pid, &status, 0); - bool failed = (abort && *abort) || + const bool aborted = abort && *abort; + const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM; + bool failed = aborted || (WIFEXITED(status) && WEXITSTATUS(status) != 0) || WIFSIGNALED(status); if (failed) { - if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { + if (expected_sigterm) { + // Route/camera teardown cancels outstanding downloader subprocesses. + // Keep that expected shutdown path quiet. + } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { rWarning("py_downloader: process killed by signal %d", WTERMSIG(status));