diff --git a/README.md b/README.md index a1f494c33cf1e0..a77a80935d59ae 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ · Community · - Try it on a comma four + Try it on a comma 3X Quick start: `bash <(curl -fsSL openpilot.comma.ai)` @@ -42,10 +42,10 @@ Using openpilot in a car ------ To use openpilot in a car, you need four things: -1. **Supported Device:** a comma four, available at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four). -2. **Software:** The setup procedure for the comma four allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version. +1. **Supported Device:** a comma 3X, available at [comma.ai/shop](https://comma.ai/shop/comma-3x). +2. **Software:** The setup procedure for the comma 3X allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version. 3. **Supported Car:** Ensure that you have one of [the 275+ supported cars](docs/CARS.md). -4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma four to your car. +4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma 3X to your car. We have detailed instructions for [how to install the harness and device in a car](https://comma.ai/setup). Note that it's possible to run openpilot on [other hardware](https://blog.comma.ai/self-driving-car-for-free/), although it's not plug-and-play. diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index f5ef0f4393946d..6f1b3014a1ae52 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -66,7 +66,7 @@ base_frameworks = qt_env['FRAMEWORKS'] base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] + base_frameworks.append('QtCharts') else: base_libs.append('Qt5Charts') diff --git a/tools/cabana/cabana b/tools/cabana/cabana index cd9bf1dd7970ad..0144e6f04013a7 100755 --- a/tools/cabana/cabana +++ b/tools/cabana/cabana @@ -33,6 +33,6 @@ fi # Build _cabana cd "$ROOT" -scons -j4 tools/cabana/_cabana cereal/messaging/bridge +scons -j"$(nproc)" tools/cabana/_cabana cereal/messaging/bridge exec "$DIR/_cabana" "$@" diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript index 122d502341c542..d8aed7535e0341 100644 --- a/tools/jotpluggler/SConscript +++ b/tools/jotpluggler/SConscript @@ -1,92 +1,65 @@ 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), +jot_env["LIBPATH"] += [imgui.MESA_DIR] +jot_env["CPPPATH"] += [imgui.INCLUDE_DIR] +jot_env["CPPPATH"] += [libusb.INCLUDE_DIR] +jot_env["LIBPATH"] += [libusb.LIB_DIR] +jot_env["CXXFLAGS"] += ["-DGLFW_INCLUDE_NONE"] +repo_root = jot_env.Dir("../..").abspath +jot_env["CXXFLAGS"] += ['-DJOTP_REPO_ROOT=\'"%s"\'' % repo_root] +generated_dbc_dir = "tools/jotpluggler/generated_dbcs" +generated_dbc_stamp = jot_env.Command( + f"{generated_dbc_dir}/.stamp", + ["materialize_generated_dbcs.py"], + f"python3 tools/jotpluggler/materialize_generated_dbcs.py --out {generated_dbc_dir}", +) + +jot_sources = [ + "main.cc", + "app.cc", + "app_browser.cc", + "app_cabana.cc", + "app_cabana_widgets.cc", + "app_camera.cc", + "app_common.cc", + "app_custom_series.cc", + "app_layout_flow.cc", + "app_layout_io.cc", + "app_logs.cc", + "app_map.cc", + "app_plot.cc", + "app_render_flow.cc", + "app_runtime.cc", + "app_session_flow.cc", + "app_sidebar_flow.cc", + "app_socketcan.cc", + "app_stream_flow.cc", + "bootstrap_icons.cc", + "dbc_core.cc", + "../cabana/panda.cc", + "sketch_layout.cc", ] -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) +objects = [] +for source in jot_sources: + basename = os.path.splitext(os.path.basename(source))[0] + objects.append(jot_env.Object(target=f"jot_{basename}", source=source)) -libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"), +imgui_static = File(f"{imgui.LIB_DIR}/libimgui.a") +glfw_static = File(f"{imgui.LIB_DIR}/libglfw3.a") +frameworks = [] +libs = [replay_lib, common, messaging, visionipc, cereal, imgui_static, glfw_static, "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"] + 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) +program = jot_env.Program("jotpluggler", objects, LIBS=libs, FRAMEWORKS=frameworks) 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 index 4b56299ead39e5..497528426891d8 100644 --- a/tools/jotpluggler/app.cc +++ b/tools/jotpluggler/app.cc @@ -1,8 +1,8 @@ -#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 "tools/jotpluggler/jotpluggler.h" +#include "tools/jotpluggler/app_camera.h" +#include "tools/jotpluggler/app_common.h" +#include "tools/jotpluggler/app_internal.h" +#include "tools/jotpluggler/app_map.h" #include "system/hardware/hw.h" #include "imgui_impl_glfw.h" @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -29,25 +32,82 @@ #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; +const fs::path &repo_root() { + static const fs::path root = []() -> fs::path { +#ifdef JOTP_REPO_ROOT + return JOTP_REPO_ROOT; +#else + std::array buf = {}; + const ssize_t count = readlink("/proc/self/exe", buf.data(), buf.size() - 1); + if (count <= 0) throw std::runtime_error("Unable to resolve executable path"); + return fs::path(std::string(buf.data(), static_cast(count))).parent_path().parent_path().parent_path(); +#endif + }(); + return root; +} + +std::optional jetbrains_mono_font_path() { + const char *home = std::getenv("HOME"); + std::vector candidates; + if (home != nullptr) { + candidates.push_back(fs::path(home) / ".local/share/fonts/fonts/ttf/JetBrainsMono-Regular.ttf"); + candidates.push_back(fs::path(home) / ".local/share/fonts/fonts/variable/JetBrainsMono[wght].ttf"); + } + candidates.push_back(fs::path("/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf")); + for (const fs::path &candidate : candidates) { + if (fs::exists(candidate)) return candidate; + } + return std::nullopt; +} + +std::optional inter_font_path() { + std::vector candidates = { + repo_root() / "selfdrive" / "assets" / "fonts" / "Inter-Regular.ttf", + repo_root() / "selfdrive" / "ui" / "installer" / "inter-ascii.ttf", + }; + for (const fs::path &candidate : candidates) { + if (fs::exists(candidate)) return candidate; + } + return std::nullopt; +} + +std::optional inter_semibold_font_path() { + const fs::path candidate = repo_root() / "selfdrive" / "assets" / "fonts" / "Inter-SemiBold.ttf"; + if (fs::exists(candidate)) { + return candidate; + } + return std::nullopt; +} + 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(); - } + if (raw.filename() != raw) return raw.filename().replace_extension("").string(); fs::path stem_path = raw; return stem_path.replace_extension("").string(); } +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 = repo_root() / "tools" / "jotpluggler" / "layouts" / (layout_name_from_arg(layout_arg) + ".json"); + if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg); + return candidate; +} + fs::path layouts_dir() { return repo_root() / "tools" / "jotpluggler" / "layouts"; } @@ -79,26 +139,12 @@ 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 scan_layout_names() { std::vector names; const fs::path root = layouts_dir(); if (!fs::exists(root) || !fs::is_directory(root)) { @@ -114,6 +160,39 @@ std::vector available_layout_names() { return names; } +std::vector available_layout_names() { + return scan_layout_names(); +} + +void run_or_throw(const std::string &command, const std::string &action) { + const int ret = std::system(command.c_str()); + if (ret != 0) throw std::runtime_error(action + " failed with exit code " + std::to_string(ret)); +} + +const char *stream_source_kind_label(StreamSourceKind kind) { + switch (kind) { + case StreamSourceKind::CerealRemote: return "Remote (ZMQ)"; + case StreamSourceKind::Panda: return "Panda"; + case StreamSourceKind::SocketCan: return "SocketCAN"; + case StreamSourceKind::CerealLocal: + default: return "Local (MSGQ)"; + } +} + +std::string stream_source_target_label(const StreamSourceConfig &source) { + switch (source.kind) { + case StreamSourceKind::CerealRemote: + return source.address.empty() ? std::string("127.0.0.1") : source.address; + case StreamSourceKind::Panda: + return source.panda.serial.empty() ? std::string("auto") : source.panda.serial; + case StreamSourceKind::SocketCan: + return source.socketcan.device.empty() ? std::string("can0") : source.socketcan.device; + case StreamSourceKind::CerealLocal: + default: + return "127.0.0.1"; + } +} + void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks) { state->tabs.clear(); cancel_rename_tab(state); @@ -136,8 +215,13 @@ void start_new_layout(AppSession *session, UiState *state, const std::string &st } 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/"); + return path.rfind("/can/", 0) == 0 || path.rfind("/sendcan/", 0) == 0; +} + +std::optional parse_can_service_kind(std::string_view service) { + if (service == "can") return CanServiceKind::Can; + if (service == "sendcan") return CanServiceKind::Sendcan; + return std::nullopt; } bool apply_route_can_decode_update(AppSession *session, UiState *state); @@ -145,10 +229,9 @@ 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)) { + if (it->first.rfind(updated_prefix, 0) == 0) { it = session->route_data.series_formats.erase(it); } else { ++it; @@ -160,7 +243,7 @@ void rebuild_series_lookup_preserving_formats(AppSession *session, 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)) { + if (!updated_prefix.empty() && series.path.rfind(updated_prefix, 0) == 0) { 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); } @@ -171,6 +254,84 @@ void rebuild_series_lookup_preserving_formats(AppSession *session, } } +bool apply_route_can_message_decode_update(AppSession *session, UiState *state, const CabanaSignalEditorState &signal) { + 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; + } + const std::optional service = parse_can_service_kind(signal.service); + if (!service.has_value()) { + return apply_route_can_decode_update(session, state); + } + const auto message_it = std::find_if(session->route_data.can_messages.begin(), session->route_data.can_messages.end(), + [&](const CanMessageData &message) { + return message.id.service == *service + && message.id.bus == static_cast(signal.bus) + && message.id.address == signal.message_address; + }); + if (message_it == session->route_data.can_messages.end()) { + return apply_route_can_decode_update(session, state); + } + + std::unordered_map message_enum_info; + std::vector message_series = decode_can_messages({*message_it}, active_dbc_name, &message_enum_info); + const std::string prefix = "/" + signal.service + "/" + std::to_string(signal.bus) + "/" + signal.message_name + "/"; + const bool paths_changed = signal.creating || signal.original_signal_name != signal.signal_name; + + std::vector updated_series; + updated_series.reserve(session->route_data.series.size() + message_series.size()); + for (RouteSeries &series : session->route_data.series) { + if (series.path.rfind(prefix, 0) != 0) { + updated_series.push_back(std::move(series)); + } + } + for (RouteSeries &series : message_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() + message_enum_info.size()); + for (auto &[path, info] : session->route_data.enum_info) { + if (path.rfind(prefix, 0) != 0) { + updated_enum_info.emplace(path, std::move(info)); + } + } + for (auto &[path, info] : message_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); + if (paths_changed) { + 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.paths.erase(std::unique(session->route_data.paths.begin(), session->route_data.paths.end()), + session->route_data.paths.end()); + session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); + } + rebuild_series_lookup_preserving_formats(session, prefix, true); + if (paths_changed) { + if (state->view_mode == AppViewMode::Plot) { + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } else { + state->browser_nodes_dirty = true; + } + } + rebuild_cabana_messages(session); + refresh_all_custom_curves(session, state); + return true; +} + 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()) { @@ -218,6 +379,7 @@ bool apply_route_can_decode_update(AppSession *session, UiState *state) { rebuild_route_index(session); rebuild_browser_nodes(session, state); + rebuild_cabana_messages(session); refresh_all_custom_curves(session, state); sync_camera_feeds(session); return true; @@ -228,7 +390,10 @@ void apply_dbc_override_change(AppSession *session, UiState *state, const std::s 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); + const bool can_patch_single_message = state->cabana_signal_editor.loaded && state->cabana_signal_editor.message_address != 0; + const bool ok = can_patch_single_message + ? apply_route_can_message_decode_update(session, state, state->cabana_signal_editor) + : 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 { @@ -249,7 +414,9 @@ void configure_style() { g_ui_font = nullptr; g_ui_bold_font = nullptr; g_mono_font = nullptr; - const fs::path fonts_dir = repo_root() / "selfdrive" / "assets" / "fonts"; + const std::optional ui_font_path = inter_font_path(); + const std::optional ui_bold_font_path = inter_semibold_font_path(); + const std::optional mono_font_path = jetbrains_mono_font_path(); ImFontConfig font_cfg; font_cfg.OversampleH = 2; font_cfg.OversampleV = 2; @@ -258,23 +425,27 @@ void configure_style() { 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); + icon_add_font(size, true); } 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; + if (ui_font_path.has_value()) { + if (ImFont *font = add_font_with_icons(*ui_font_path, 16.0f); font != nullptr) { + g_ui_font = font; + io.FontDefault = font; + } + } + if (ui_bold_font_path.has_value()) { + g_ui_bold_font = add_font_with_icons(*ui_bold_font_path, 16.75f); } - 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) { + if (g_ui_font == nullptr && mono_font_path.has_value()) { + if (ImFont *font = add_font_with_icons(*mono_font_path, 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_mono_font == nullptr && mono_font_path.has_value()) { + g_mono_font = add_font_with_icons(*mono_font_path, 15.75f); } if (g_ui_bold_font == nullptr) { g_ui_bold_font = g_ui_font; @@ -375,6 +546,16 @@ UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar return ui; } +template +void copy_to_buffer(const std::string &value, std::array *buffer) { + buffer->fill('\0'); + if constexpr (N > 0) { + const size_t count = std::min(value.size(), N - 1); + std::copy_n(value.data(), count, buffer->data()); + (*buffer)[count] = '\0'; + } +} + void sync_ui_state(UiState *state, const SketchLayout &layout) { const bool initializing = state->tabs.empty(); state->tabs.resize(layout.tabs.size()); @@ -408,13 +589,20 @@ void resize_tab_pane_state(TabUiState *tab_state, size_t pane_count) { } void sync_route_buffers(UiState *state, const AppSession &session) { - state->route_buffer = session.route_name; - state->data_dir_buffer = session.data_dir; + copy_to_buffer(session.route_name, &state->route_buffer); + copy_to_buffer(session.data_dir, &state->data_dir_buffer); } void sync_stream_buffers(UiState *state, const AppSession &session) { - state->stream_address_buffer = session.stream_source.address; + copy_to_buffer(session.stream_source.address, &state->stream_address_buffer); + copy_to_buffer(session.stream_source.panda.serial, &state->panda_serial_buffer); + copy_to_buffer(session.stream_source.socketcan.device, &state->socketcan_device_buffer); state->stream_source_kind = session.stream_source.kind; + for (size_t i = 0; i < session.stream_source.panda.buses.size(); ++i) { + state->panda_can_speed_kbps[i] = session.stream_source.panda.buses[i].can_speed_kbps; + state->panda_data_speed_kbps[i] = session.stream_source.panda.buses[i].data_speed_kbps; + state->panda_can_fd[i] = session.stream_source.panda.buses[i].can_fd; + } state->stream_buffer_seconds = session.stream_buffer_seconds; } @@ -423,8 +611,8 @@ fs::path default_layout_save_path(const AppSession &session) { } 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(); + copy_to_buffer(session.layout_path.empty() ? std::string() : session.layout_path.string(), &state->load_layout_buffer); + copy_to_buffer(default_layout_save_path(session).string(), &state->save_layout_buffer); } const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state) { @@ -447,11 +635,15 @@ TabUiState *app_active_tab_state(UiState *state) { 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); + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s###tab%d_pane%d", title, tab_runtime_id, pane_index); + return buf; } 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); + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id); + return buf; } void request_tab_selection(UiState *state, int tab_index) { @@ -463,7 +655,7 @@ 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; + copy_to_buffer(layout.tabs[static_cast(tab_index)].tab_name, &state->rename_tab_buffer); state->rename_tab_index = tab_index; state->focus_rename_tab_input = true; request_tab_selection(state, tab_index); @@ -475,7 +667,9 @@ void cancel_rename_tab(UiState *state) { } ImGuiID dockspace_id_for_tab(int tab_runtime_id) { - return ImHashStr(util::string_format("jotpluggler_dockspace_%d", tab_runtime_id).c_str()); + char buf[48]; + std::snprintf(buf, sizeof(buf), "jotpluggler_dockspace_%d", tab_runtime_id); + return ImHashStr(buf); } bool curve_has_local_samples(const Curve &curve) { @@ -640,7 +834,7 @@ std::string next_tab_name(const SketchLayout &layout, const std::string &base_na 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")) { + if (tab.tab_name.size() > 3 && tab.tab_name.rfind("tab", 0) == 0) { 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)); @@ -692,7 +886,7 @@ bool active_tab_has_map_pane(const SketchLayout &layout) { 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); + return pane_is_special(pane); }); } @@ -815,11 +1009,7 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool } 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::BeginCombo("##layout_combo", layout_combo_label(*session, *state).c_str())) { if (ImGui::Selectable("New Layout")) { start_new_layout(session, state); } @@ -857,7 +1047,7 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool ImGui::SeparatorText("Data Sources"); ImGui::SetNextItemWidth(-FLT_MIN); - input_text_with_hint_string("##browser_filter", "Search...", &state->browser_filter); + ImGui::InputTextWithHint("##browser_filter", "Search...", state->browser_filter.data(), state->browser_filter.size()); const float footer_height = ImGui::GetFrameHeightWithSpacing() + ImGui::GetTextLineHeightWithSpacing() + 16.0f @@ -866,12 +1056,12 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool 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); + const std::string filter = lowercase(state->browser_filter.data()); std::vector visible_paths; for (const BrowserNode &node : session->browser_nodes) { collect_visible_leaf_paths(node, filter, &visible_paths); } - for (const SpecialItemSpec &spec : kSpecialItemSpecs) { + for (const SpecialItemSpec &spec : special_item_specs()) { draw_browser_special_item(spec.id, spec.label); } ImGui::Dummy(ImVec2(0.0f, 2.0f)); @@ -1013,13 +1203,17 @@ bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std return add_path_curve_to_pane(session, state, tab_state->active_pane_index, path); } +bool pane_is_empty_for_special_item(const Pane &pane) { + return pane.kind == PaneKind::Plot && pane.curves.empty(); +} + 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))) { + if (!(pane_is_empty_for_special_item(pane) || pane_kind_is_special(pane.kind))) { return false; } if (pane.kind == spec->kind && (spec->kind != PaneKind::Camera || pane.camera_view == spec->camera_view)) { @@ -1148,7 +1342,7 @@ 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; + layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer.data(); state->status_text = "Renamed tab"; layout->current_tab_index = state->rename_tab_index; cancel_rename_tab(state); @@ -1169,9 +1363,10 @@ void draw_inline_tab_editor(AppSession *session, UiState *state, const ImRect &t 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 submitted = ImGui::InputText("##rename_tab_inline", + state->rename_tab_buffer.data(), + state->rename_tab_buffer.size(), + ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue); const bool active = ImGui::IsItemActive(); const bool escape = active && ImGui::IsKeyPressed(ImGuiKey_Escape); const bool deactivated = ImGui::IsItemDeactivated(); @@ -1250,7 +1445,7 @@ std::optional draw_pane_drop_target(int tab_index, int pane_inde 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 (zone.zone != PaneDropZone::Center || pane_is_empty_for_special_item(target_pane) || 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); @@ -1384,9 +1579,8 @@ bool apply_pane_drop_action(AppSession *session, UiState *state, const PaneDropA 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))) { + if (!(pane_is_empty_for_special_item(tab->panes[static_cast(action.target_pane_index)]) + || 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; } @@ -1834,6 +2028,7 @@ int run(const Options &options) { .layout = options.layout.empty() ? make_empty_layout() : load_sketch_layout(layout_path), }; UiState ui_state; + ui_state.start_cabana = options.start_cabana; 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; diff --git a/tools/jotpluggler/app_browser.cc b/tools/jotpluggler/app_browser.cc new file mode 100644 index 00000000000000..5773df0e2ba484 --- /dev/null +++ b/tools/jotpluggler/app_browser.cc @@ -0,0 +1,465 @@ +#include "tools/jotpluggler/jotpluggler.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(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/app_cabana.cc b/tools/jotpluggler/app_cabana.cc new file mode 100644 index 00000000000000..ad838b774ea293 --- /dev/null +++ b/tools/jotpluggler/app_cabana.cc @@ -0,0 +1,2507 @@ +#include "tools/jotpluggler/app_internal.h" + +#include "implot.h" +#include "imgui_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr float kSplitterThickness = 4.0f; +constexpr float kMinMessagesWidth = 210.0f; +constexpr float kMinCenterWidth = 240.0f; +constexpr float kMinRightWidth = 260.0f; +constexpr float kMinTopHeight = 140.0f; +constexpr float kMinBottomHeight = 120.0f; +constexpr std::array, 8> kSignalHighlightColors = {{ + {102, 86, 169}, + {69, 137, 255}, + {55, 171, 112}, + {232, 171, 44}, + {198, 89, 71}, + {92, 155, 181}, + {134, 172, 79}, + {150, 112, 63}, +}}; + +std::optional parse_can_service_kind(std::string_view service) { + if (service == "can") return CanServiceKind::Can; + if (service == "sendcan") return CanServiceKind::Sendcan; + return std::nullopt; +} + +const char *can_service_name(CanServiceKind service) { + return service == CanServiceKind::Can ? "can" : "sendcan"; +} + +std::string format_can_address(uint32_t address) { + char text[32]; + std::snprintf(text, sizeof(text), "0x%X", address); + return text; +} + +std::string cabana_message_id_label(const CabanaMessageSummary &message) { + char text[32]; + std::snprintf(text, sizeof(text), "%d:%X", message.bus, message.address); + return text; +} + +std::string can_message_key(CanServiceKind service, uint8_t bus, uint32_t address) { + return "/" + std::string(can_service_name(service)) + "/" + std::to_string(bus) + "/" + format_can_address(address); +} + +int cabana_flip_bit_pos(int bit_pos) { + return 8 * (bit_pos / 8) + 7 - (bit_pos % 8); +} + +int cabana_visual_index(size_t byte_index, int bit_index) { + return static_cast(byte_index) * 8 + (7 - bit_index); +} + +std::string sanitize_filename_component(std::string_view text) { + std::string out; + out.reserve(text.size()); + for (char c : text) { + if (std::isalnum(static_cast(c)) || c == '-' || c == '_') { + out.push_back(c); + } else { + out.push_back('_'); + } + } + return out.empty() ? "untitled" : out; +} + +fs::path cabana_export_dir() { + const char *home = std::getenv("HOME"); + fs::path root = home != nullptr ? fs::path(home) : fs::current_path(); + const fs::path downloads = root / "Downloads"; + return (fs::exists(downloads) ? downloads : root) / "jotpluggler_exports"; +} + +std::string csv_escape(std::string_view text) { + std::string out; + out.reserve(text.size() + 2); + out.push_back('"'); + for (char c : text) { + if (c == '"') out.push_back('"'); + out.push_back(c); + } + out.push_back('"'); + return out; +} + +std::string payload_hex(const std::string &data) { + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(data.size() * 2); + for (unsigned char byte : data) { + out.push_back(kHex[byte >> 4]); + out.push_back(kHex[byte & 0xF]); + } + return out; +} + +fs::path cabana_export_path(const AppSession &session, + const CabanaMessageSummary &message, + std::string_view kind) { + const std::string route_part = sanitize_filename_component(session.route_name.empty() ? "stream" : session.route_name); + char filename[256]; + std::snprintf(filename, sizeof(filename), "%s_%s_bus%d_0x%X_%.*s.csv", + sanitize_filename_component(message.name).c_str(), + sanitize_filename_component(message.service).c_str(), + message.bus, + message.address, + static_cast(kind.size()), + kind.data()); + return cabana_export_dir() / route_part / filename; +} + +std::optional load_active_dbc(const AppSession &session) { + const std::string &dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name; + return load_dbc_by_name(dbc_name); +} + +struct BitBehaviorStats { + double ones_ratio = 0.0; + double flip_ratio = 0.0; + size_t samples = 0; +}; + +struct BinaryMatrixLayout { + size_t byte_count = 0; + std::vector> cell_signals; + std::vector is_msb; + std::vector is_lsb; + size_t overlapping_cells = 0; +}; + +bool signal_contains_bit(const CabanaSignalSummary &signal, size_t byte_index, int bit_index); +void sync_cabana_selection(AppSession *session, UiState *state); +void clear_similar_bit_results(UiState *state); +const CabanaSignalSummary *find_signal_by_path(const CabanaMessageSummary &message, std::string_view path); +bool prepare_cabana_signal_editor(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + const CabanaSignalSummary &signal); +bool prepare_cabana_new_signal_editor(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + int start_bit, + int size, + bool is_little_endian); + +void clear_cabana_binary_drag(UiState *state) { + state->cabana.binary_drag_active = false; + state->cabana.binary_drag_resizing = false; + state->cabana.binary_drag_moved = false; + state->cabana.binary_drag_signal_is_little_endian = true; + state->cabana.binary_drag_press_byte = -1; + state->cabana.binary_drag_press_bit = -1; + state->cabana.binary_drag_anchor_byte = -1; + state->cabana.binary_drag_anchor_bit = -1; + state->cabana.binary_drag_current_byte = -1; + state->cabana.binary_drag_current_bit = -1; + state->cabana.binary_drag_signal_path.clear(); +} + +std::string_view active_signal_path(const UiState &state) { + if (!state.cabana.selected_signal_path.empty()) { + return state.cabana.selected_signal_path; + } + return {}; +} + +void set_cabana_selected_bit(UiState *state, int byte_index, int bit_index) { + const bool changed = !state->cabana.has_bit_selection + || state->cabana.selected_bit_byte != byte_index + || state->cabana.selected_bit_index != bit_index; + state->cabana.has_bit_selection = true; + state->cabana.selected_bit_byte = byte_index; + state->cabana.selected_bit_index = bit_index; + if (changed) { + clear_similar_bit_results(state); + } +} + +bool cabana_drag_has_selection(const UiState &state) { + return state.cabana.binary_drag_active + && state.cabana.binary_drag_anchor_byte >= 0 + && state.cabana.binary_drag_anchor_bit >= 0 + && state.cabana.binary_drag_current_byte >= 0 + && state.cabana.binary_drag_current_bit >= 0; +} + +bool cabana_drag_selection(const UiState &state, int *start_bit, int *size, bool *is_little_endian) { + if (!cabana_drag_has_selection(state)) { + return false; + } + const int anchor_visual = cabana_visual_index(static_cast(state.cabana.binary_drag_anchor_byte), + state.cabana.binary_drag_anchor_bit); + const int current_visual = cabana_visual_index(static_cast(state.cabana.binary_drag_current_byte), + state.cabana.binary_drag_current_bit); + const int anchor_bit_pos = state.cabana.binary_drag_anchor_byte * 8 + state.cabana.binary_drag_anchor_bit; + const int current_bit_pos = state.cabana.binary_drag_current_byte * 8 + state.cabana.binary_drag_current_bit; + + bool little_endian = true; + if (state.cabana.binary_drag_resizing) { + little_endian = state.cabana.binary_drag_signal_is_little_endian; + } else { + // Match old Cabana's default MsbFirst drag direction. + little_endian = current_visual < anchor_visual; + } + + const int visual_min = std::min(anchor_visual, current_visual); + *start_bit = little_endian ? std::min(anchor_bit_pos, current_bit_pos) + : cabana_flip_bit_pos(visual_min); + *size = little_endian ? std::abs(current_bit_pos - anchor_bit_pos) + 1 + : std::abs(cabana_flip_bit_pos(current_bit_pos) - cabana_flip_bit_pos(anchor_bit_pos)) + 1; + *is_little_endian = little_endian; + return *size > 0; +} + +bool cabana_selection_contains_bit(int start_bit, int size, bool is_little_endian, size_t byte_index, int bit_index) { + dbc::Signal signal; + signal.start_bit = start_bit; + signal.size = size; + signal.is_little_endian = is_little_endian; + dbc::updateMsbLsb(&signal); + CabanaSignalSummary summary{ + .start_bit = signal.start_bit, + .msb = signal.msb, + .lsb = signal.lsb, + .size = signal.size, + .is_little_endian = signal.is_little_endian, + .has_bit_range = true, + }; + return signal_contains_bit(summary, byte_index, bit_index); +} + +bool cabana_drag_selection_contains_bit(const UiState &state, size_t byte_index, int bit_index) { + int start_bit = 0; + int size = 0; + bool is_little_endian = true; + return cabana_drag_selection(state, &start_bit, &size, &is_little_endian) + && cabana_selection_contains_bit(start_bit, size, is_little_endian, byte_index, bit_index); +} + +bool cabana_drag_selection_has_neighbor(const UiState &state, int byte_index, int bit_index) { + if (byte_index < 0 || bit_index < 0 || bit_index > 7) { + return false; + } + return cabana_drag_selection_contains_bit(state, static_cast(byte_index), bit_index); +} + +bool queue_binary_drag_apply(AppSession *session, + const CabanaMessageSummary &summary, + UiState *state, + int release_byte, + int release_bit, + const CabanaSignalSummary *clicked_signal) { + if (!state->cabana.binary_drag_active) { + return false; + } + if (state->cabana.binary_drag_moved) { + int start_bit = 0; + int size = 0; + bool is_little_endian = true; + if (!cabana_drag_selection(*state, &start_bit, &size, &is_little_endian) || size <= 0) { + return false; + } + bool queued_apply = false; + if (state->cabana.binary_drag_resizing) { + const CabanaSignalSummary *target = find_signal_by_path(summary, state->cabana.binary_drag_signal_path); + if (target != nullptr && prepare_cabana_signal_editor(*session, state, summary, *target)) { + state->cabana_signal_editor.open = false; + state->cabana_signal_editor.start_bit = start_bit; + state->cabana_signal_editor.size = size; + state->cabana_signal_editor.is_little_endian = is_little_endian; + state->cabana.pending_apply_signal_edit = true; + queued_apply = true; + } + } else if (size > 1 && prepare_cabana_new_signal_editor(*session, state, summary, start_bit, size, is_little_endian)) { + state->cabana_signal_editor.open = false; + state->cabana.pending_apply_signal_edit = true; + queued_apply = true; + } + if (queued_apply) { + set_cabana_selected_bit(state, release_byte, release_bit); + } + return queued_apply; + } + if (clicked_signal != nullptr) { + state->cabana.selected_signal_path = clicked_signal->path; + } + set_cabana_selected_bit(state, release_byte, release_bit); + return true; +} + +bool contains_case_insensitive(std::string_view haystack, std::string_view needle) { + if (needle.empty()) { + return true; + } + const std::string hay = lowercase(haystack); + const std::string ndl = lowercase(needle); + return hay.find(ndl) != std::string::npos; +} + +bool cabana_match_numeric_filter(std::string_view filter, double value) { + const std::string raw = trim_copy(filter); + if (raw.empty()) { + return true; + } + const size_t dash = raw.find('-'); + if (dash != std::string::npos) { + if (raw.find('-', dash + 1) != std::string::npos) { + return false; + } + const std::string lo_text = raw.substr(0, dash); + const std::string hi_text = raw.substr(dash + 1); + char *lo_end = nullptr; + char *hi_end = nullptr; + const double lo = lo_text.empty() ? -1.0e18 : std::strtod(lo_text.c_str(), &lo_end); + const double hi = hi_text.empty() ? 1.0e18 : std::strtod(hi_text.c_str(), &hi_end); + if ((!lo_text.empty() && lo_end == lo_text.c_str()) || (!hi_text.empty() && hi_end == hi_text.c_str())) { + return false; + } + return value >= lo && value <= hi; + } + char *end = nullptr; + const double target = std::strtod(raw.c_str(), &end); + return end != raw.c_str() && static_cast(value) == static_cast(target); +} + +bool cabana_match_address_filter(std::string_view filter, uint32_t address) { + const std::string raw = trim_copy(filter); + if (raw.empty()) { + return true; + } + const size_t dash = raw.find('-'); + if (dash != std::string::npos && dash > 0 && dash + 1 < raw.size()) { + const std::string lo_text = raw.substr(0, dash); + const std::string hi_text = raw.substr(dash + 1); + char *lo_end = nullptr; + char *hi_end = nullptr; + const unsigned long lo = std::strtoul(lo_text.c_str(), &lo_end, 16); + const unsigned long hi = std::strtoul(hi_text.c_str(), &hi_end, 16); + if (lo_end != lo_text.c_str() && hi_end != hi_text.c_str()) { + return address >= lo && address <= hi; + } + } + return contains_case_insensitive(format_can_address(address), raw); +} + +bool cabana_message_matches_filters(const CabanaMessageSummary &message, + std::string_view name_filter, + std::string_view bus_filter, + std::string_view addr_filter, + std::string_view node_filter, + std::string_view freq_filter, + std::string_view count_filter, + std::string_view bytes_filter, + const CanMessageData *message_data) { + const bool name_matches = [&] { + if (name_filter.empty()) { + return true; + } + if (contains_case_insensitive(message.name, name_filter)) { + return true; + } + return std::any_of(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) { + return contains_case_insensitive(signal.name, name_filter); + }); + }(); + const bool bytes_matches = [&] { + if (bytes_filter.empty()) { + return true; + } + if (message_data == nullptr || message_data->samples.empty()) { + return false; + } + return contains_case_insensitive(payload_hex(message_data->samples.back().data), trim_copy(bytes_filter)); + }(); + return name_matches + && cabana_match_numeric_filter(bus_filter, message.bus) + && cabana_match_address_filter(addr_filter, message.address) + && contains_case_insensitive(message.node, node_filter) + && cabana_match_numeric_filter(freq_filter, message.frequency_hz) + && cabana_match_numeric_filter(count_filter, static_cast(message.sample_count)) + && bytes_matches; +} + +const CanMessageData *find_message_data(const AppSession &session, const CabanaMessageSummary &message) { + const std::optional service = parse_can_service_kind(message.service); + if (!service.has_value()) { + return nullptr; + } + const CanMessageData key{.id = CanMessageId{*service, static_cast(message.bus), message.address}}; + auto it = std::lower_bound(session.route_data.can_messages.begin(), + session.route_data.can_messages.end(), + key, + [](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); + }); + if (it == session.route_data.can_messages.end() + || it->id.service != key.id.service + || it->id.bus != key.id.bus + || it->id.address != key.id.address) { + return nullptr; + } + return &*it; +} + +bool prepare_cabana_signal_editor(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + const CabanaSignalSummary &signal) { + const std::optional db = load_active_dbc(session); + const dbc::Message *dbc_message = db.has_value() ? db->message(message.address) : nullptr; + if (dbc_message == nullptr) { + state->error_text = "No active DBC message available for editing"; + state->open_error_popup = true; + return false; + } + auto it = std::find_if(dbc_message->signals.begin(), dbc_message->signals.end(), [&](const dbc::Signal &dbc_signal) { + return dbc_signal.name == signal.name; + }); + if (it == dbc_message->signals.end()) { + state->error_text = "Signal not found in active DBC"; + state->open_error_popup = true; + return false; + } + + CabanaSignalEditorState &editor = state->cabana_signal_editor; + editor.loaded = true; + editor.creating = false; + editor.message_root = message.root_path; + editor.message_name = message.name; + editor.service = message.service; + editor.signal_path = signal.path; + editor.bus = message.bus; + editor.message_address = message.address; + editor.original_signal_name = it->name; + editor.signal_name = it->name; + editor.start_bit = it->start_bit; + editor.size = it->size; + editor.factor = it->factor; + editor.offset = it->offset; + editor.min = it->min; + editor.max = it->max; + editor.is_signed = it->is_signed; + editor.is_little_endian = it->is_little_endian; + editor.type = static_cast(it->type); + editor.multiplex_value = it->multiplex_value; + editor.receiver_name = it->receiver_name; + editor.unit = it->unit; + return true; +} + +bool prepare_cabana_new_signal_editor(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + int start_bit, + int size, + bool is_little_endian) { + const std::optional db = load_active_dbc(session); + const dbc::Message *dbc_message = db.has_value() ? db->message(message.address) : nullptr; + if (dbc_message == nullptr) { + state->error_text = "No active DBC message available for creating a signal"; + state->open_error_popup = true; + return false; + } + + const int byte_index = start_bit / 8; + const int bit_index = start_bit & 7; + std::string base_name = "bit_" + std::to_string(byte_index) + "_" + std::to_string(bit_index); + std::string signal_name = base_name; + int suffix = 2; + auto exists = [&](std::string_view candidate) { + return std::any_of(dbc_message->signals.begin(), dbc_message->signals.end(), [&](const dbc::Signal &signal) { + return signal.name == candidate; + }); + }; + while (exists(signal_name)) { + signal_name = base_name + "_" + std::to_string(suffix++); + } + + CabanaSignalEditorState &editor = state->cabana_signal_editor; + editor.loaded = true; + editor.creating = true; + editor.message_root = message.root_path; + editor.message_name = message.name; + editor.service = message.service; + editor.signal_path.clear(); + editor.bus = message.bus; + editor.message_address = message.address; + editor.original_signal_name.clear(); + editor.signal_name = signal_name; + editor.start_bit = start_bit; + editor.size = size; + editor.factor = 1.0; + editor.offset = 0.0; + editor.min = 0.0; + editor.max = std::min(std::pow(2.0, static_cast(std::min(size, 24))) - 1.0, 1.0e9); + editor.is_signed = false; + editor.is_little_endian = is_little_endian; + editor.type = static_cast(dbc::Signal::Type::Normal); + editor.multiplex_value = 0; + editor.receiver_name = "XXX"; + editor.unit.clear(); + return true; +} + +void open_cabana_new_signal_editor(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + int byte_index, + int bit_index) { + if (prepare_cabana_new_signal_editor(session, state, message, byte_index * 8 + bit_index, 1, true)) { + state->cabana_signal_editor.open = true; + } +} + +const CabanaMessageSummary *find_selected_message(const AppSession &session, const UiState &state) { + auto it = std::find_if(session.cabana_messages.begin(), session.cabana_messages.end(), [&](const CabanaMessageSummary &message) { + return message.root_path == state.cabana.selected_message_root; + }); + return it == session.cabana_messages.end() ? nullptr : &*it; +} + +const CabanaMessageSummary *find_message_by_root(const AppSession &session, std::string_view root_path) { + auto it = std::find_if(session.cabana_messages.begin(), session.cabana_messages.end(), [&](const CabanaMessageSummary &message) { + return message.root_path == root_path; + }); + return it == session.cabana_messages.end() ? nullptr : &*it; +} + +void select_cabana_message(AppSession *session, UiState *state, std::string_view root_path) { + state->cabana.selected_message_root.assign(root_path); + state->cabana.sync_message_tabs = true; + if (std::find(state->cabana.open_message_roots.begin(), state->cabana.open_message_roots.end(), root_path) + == state->cabana.open_message_roots.end()) { + state->cabana.open_message_roots.emplace_back(root_path); + } + state->cabana.signal_filter[0] = '\0'; + state->cabana.selected_signal_path.clear(); + state->cabana.detail_top_auto_fit = true; + state->cabana.has_bit_selection = false; + clear_similar_bit_results(state); + clear_cabana_binary_drag(state); + sync_cabana_selection(session, state); +} + +void close_cabana_message_tab(AppSession *session, UiState *state, std::string_view root_path) { + auto &roots = state->cabana.open_message_roots; + auto it = std::find(roots.begin(), roots.end(), root_path); + if (it == roots.end()) { + return; + } + const bool closing_selected = state->cabana.selected_message_root == root_path; + const size_t index = static_cast(it - roots.begin()); + roots.erase(it); + if (!closing_selected) { + return; + } + if (roots.empty()) { + state->cabana.selected_message_root.clear(); + state->cabana.selected_signal_path.clear(); + state->cabana.has_bit_selection = false; + clear_similar_bit_results(state); + clear_cabana_binary_drag(state); + return; + } + const size_t next_index = std::min(index, roots.size() - 1); + select_cabana_message(session, state, roots[next_index]); +} + +bool similar_bit_results_match_selection(const UiState &state) { + return state.cabana.has_bit_selection + && state.cabana.similar_bits_source_root == state.cabana.selected_message_root + && state.cabana.similar_bits_source_byte == state.cabana.selected_bit_byte + && state.cabana.similar_bits_source_bit == state.cabana.selected_bit_index; +} + +void clear_similar_bit_results(UiState *state) { + state->cabana.similar_bits_source_root.clear(); + state->cabana.similar_bits_source_byte = -1; + state->cabana.similar_bits_source_bit = -1; + state->cabana.similar_bit_matches.clear(); +} + +void poll_similar_bit_search(UiState *state) { + if (!state->cabana.similar_bits_loading || !state->cabana.similar_bit_future.valid()) { + return; + } + using namespace std::chrono_literals; + if (state->cabana.similar_bit_future.wait_for(0ms) != std::future_status::ready) { + return; + } + std::vector matches = state->cabana.similar_bit_future.get(); + state->cabana.similar_bits_loading = false; + if (similar_bit_results_match_selection(*state)) { + state->cabana.similar_bit_matches = std::move(matches); + } else { + clear_similar_bit_results(state); + } +} + +void sync_cabana_selection(AppSession *session, UiState *state) { + poll_similar_bit_search(state); + if (!state->cabana_mode_initialized) { + state->cabana.camera_view = sidebar_preview_camera_view(*session); + state->cabana_mode_initialized = true; + } + auto &open_roots = state->cabana.open_message_roots; + open_roots.erase(std::remove_if(open_roots.begin(), open_roots.end(), [&](const std::string &root_path) { + return find_message_by_root(*session, root_path) == nullptr; + }), + open_roots.end()); + if (session->cabana_messages.empty()) { + state->cabana.selected_message_root.clear(); + state->cabana.selected_signal_path.clear(); + state->cabana.open_message_roots.clear(); + state->cabana.chart_signal_paths.clear(); + state->cabana.has_bit_selection = false; + clear_similar_bit_results(state); + clear_cabana_binary_drag(state); + return; + } + const CabanaMessageSummary *selected = find_selected_message(*session, *state); + if (selected == nullptr) { + state->cabana.selected_message_root.clear(); + state->cabana.selected_signal_path.clear(); + state->cabana.has_bit_selection = false; + clear_similar_bit_results(state); + clear_cabana_binary_drag(state); + return; + } + + std::unordered_set allowed; + allowed.reserve(selected->signals.size()); + for (const CabanaSignalSummary &signal : selected->signals) { + allowed.insert(signal.path); + } + state->cabana.chart_signal_paths.erase( + std::remove_if(state->cabana.chart_signal_paths.begin(), state->cabana.chart_signal_paths.end(), + [&](const std::string &path) { return session->series_by_path.find(path) == session->series_by_path.end(); }), + state->cabana.chart_signal_paths.end()); + if (!state->cabana.selected_signal_path.empty() && !allowed.count(state->cabana.selected_signal_path)) { + state->cabana.selected_signal_path.clear(); + } +} + +std::string format_cabana_time(double seconds) { + seconds = std::max(0.0, seconds); + const int total = static_cast(seconds); + const int minutes = total / 60; + const int secs = total % 60; + char text[32]; + std::snprintf(text, sizeof(text), "%02d:%02d", minutes, secs); + return text; +} + +void draw_cabana_message_tabs(AppSession *session, UiState *state) { + auto &roots = state->cabana.open_message_roots; + if (roots.size() <= 1) { + return; + } + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 0.0f)); + ImGui::BeginChild("##cabana_message_tabs", ImVec2(0.0f, 28.0f), false, ImGuiWindowFlags_NoScrollbar); + int close_index = -1; + int close_others_index = -1; + if (ImGui::BeginTabBar("##cabana_message_tabbar", + ImGuiTabBarFlags_FittingPolicyResizeDown | + ImGuiTabBarFlags_Reorderable | + ImGuiTabBarFlags_NoTooltip)) { + for (size_t i = 0; i < roots.size(); ++i) { + const CabanaMessageSummary *message = find_message_by_root(*session, roots[i]); + if (message == nullptr) { + continue; + } + const std::string message_id = cabana_message_id_label(*message); + const std::string label = message_id + "###cabana_tab_" + roots[i]; + bool open = true; + const ImGuiTabItemFlags flags = (state->cabana.sync_message_tabs && state->cabana.selected_message_root == roots[i]) + ? ImGuiTabItemFlags_SetSelected + : 0; + if (ImGui::BeginTabItem(label.c_str(), &open, flags)) { + if (!state->cabana.sync_message_tabs && state->cabana.selected_message_root != roots[i]) { + select_cabana_message(session, state, roots[i]); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", message->name.c_str()); + } + if (ImGui::BeginPopupContextItem(("##cabana_tab_ctx_" + roots[i]).c_str())) { + if (ImGui::MenuItem("Close Other Tabs", nullptr, false, roots.size() > 1)) { + close_others_index = static_cast(i); + } + ImGui::EndPopup(); + } + ImGui::EndTabItem(); + } + if (!open) { + close_index = static_cast(i); + } + } + ImGui::EndTabBar(); + } + if (close_others_index >= 0) { + const std::string keep = roots[static_cast(close_others_index)]; + roots.assign(1, keep); + select_cabana_message(session, state, keep); + } else if (close_index >= 0) { + close_cabana_message_tab(session, state, roots[static_cast(close_index)]); + } + state->cabana.sync_message_tabs = false; + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); +} + +bool export_raw_can_csv(const AppSession &session, + const CabanaMessageSummary &message, + std::string *error, + fs::path *output_path) { + const CanMessageData *message_data = find_message_data(session, message); + if (message_data == nullptr || message_data->samples.empty()) { + if (error != nullptr) *error = "No raw CAN frames available"; + return false; + } + + const fs::path output = cabana_export_path(session, message, "raw"); + fs::create_directories(output.parent_path()); + std::ofstream out(output); + if (!out.is_open()) { + if (error != nullptr) *error = "Failed to open raw CSV"; + return false; + } + + out << "mono_time,bus_time,dt_ms,data_hex\n"; + for (size_t i = 0; i < message_data->samples.size(); ++i) { + const CanFrameSample &sample = message_data->samples[i]; + out << sample.mono_time << ',' + << sample.bus_time << ','; + if (i > 0) { + out << 1000.0 * (sample.mono_time - message_data->samples[i - 1].mono_time); + } + out << ',' << csv_escape(payload_hex(sample.data)) << '\n'; + } + + if (!out.good()) { + if (error != nullptr) *error = "Failed while writing raw CSV"; + return false; + } + if (output_path != nullptr) *output_path = output; + return true; +} + +bool export_decoded_can_csv(const AppSession &session, + const CabanaMessageSummary &message, + std::string *error, + fs::path *output_path) { + const CanMessageData *message_data = find_message_data(session, message); + if (message_data == nullptr || message_data->samples.empty()) { + if (error != nullptr) *error = "No raw CAN frames available"; + return false; + } + if (message.signals.empty()) { + if (error != nullptr) *error = "No decoded signals for this message"; + return false; + } + + std::vector export_signals; + std::vector export_series; + std::vector export_formats; + std::vector export_enums; + export_signals.reserve(message.signals.size()); + export_series.reserve(message.signals.size()); + export_formats.reserve(message.signals.size()); + export_enums.reserve(message.signals.size()); + + for (const CabanaSignalSummary &signal : message.signals) { + const RouteSeries *series = app_find_route_series(session, signal.path); + if (series == nullptr) { + continue; + } + export_signals.push_back(&signal); + export_series.push_back(series); + auto format_it = session.route_data.series_formats.find(signal.path); + export_formats.push_back(format_it == session.route_data.series_formats.end() ? nullptr : &format_it->second); + auto enum_it = session.route_data.enum_info.find(signal.path); + export_enums.push_back(enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second); + } + + if (export_series.empty()) { + if (error != nullptr) *error = "No decoded signal data available"; + return false; + } + + const fs::path output = cabana_export_path(session, message, "decoded"); + fs::create_directories(output.parent_path()); + std::ofstream out(output); + if (!out.is_open()) { + if (error != nullptr) *error = "Failed to open decoded CSV"; + return false; + } + + out << "mono_time,bus_time,dt_ms,data_hex"; + for (const CabanaSignalSummary *signal : export_signals) { + out << ',' << csv_escape(signal->name); + } + out << '\n'; + + for (size_t i = 0; i < message_data->samples.size(); ++i) { + const CanFrameSample &sample = message_data->samples[i]; + out << sample.mono_time << ',' + << sample.bus_time << ','; + if (i > 0) { + out << 1000.0 * (sample.mono_time - message_data->samples[i - 1].mono_time); + } + out << ',' << csv_escape(payload_hex(sample.data)); + for (size_t j = 0; j < export_series.size(); ++j) { + out << ','; + const std::optional value = app_sample_xy_value_at_time( + export_series[j]->times, export_series[j]->values, false, sample.mono_time); + if (value.has_value()) { + out << csv_escape(export_formats[j] != nullptr + ? format_display_value(*value, *export_formats[j], export_enums[j]) + : std::to_string(*value)); + } + } + out << '\n'; + } + + if (!out.good()) { + if (error != nullptr) *error = "Failed while writing decoded CSV"; + return false; + } + if (output_path != nullptr) *output_path = output; + return true; +} + +size_t closest_can_sample_index(const CanMessageData &message, double tracker_time) { + if (message.samples.empty()) { + return 0; + } + auto it = std::lower_bound(message.samples.begin(), message.samples.end(), tracker_time, + [](const CanFrameSample &sample, double time) { + return sample.mono_time < time; + }); + if (it == message.samples.begin()) { + return 0; + } + if (it == message.samples.end()) { + return message.samples.size() - 1; + } + const size_t upper = static_cast(it - message.samples.begin()); + const size_t lower = upper - 1; + return std::abs(message.samples[upper].mono_time - tracker_time) < std::abs(message.samples[lower].mono_time - tracker_time) + ? upper + : lower; +} + +uint8_t can_bit(const std::string &data, size_t byte_index, int bit_index) { + if (byte_index >= data.size() || bit_index < 0 || bit_index > 7) { + return 0; + } + return (static_cast(data[byte_index]) >> bit_index) & 0x1; +} + +BitBehaviorStats bit_behavior_stats(const CanMessageData &message, size_t byte_index, int bit_index) { + BitBehaviorStats stats; + if (message.samples.empty()) { + return stats; + } + size_t ones = 0; + size_t flips = 0; + uint8_t prev = can_bit(message.samples.front().data, byte_index, bit_index); + ones += prev; + for (size_t i = 1; i < message.samples.size(); ++i) { + const uint8_t bit = can_bit(message.samples[i].data, byte_index, bit_index); + ones += bit; + flips += bit != prev; + prev = bit; + } + stats.samples = message.samples.size(); + stats.ones_ratio = static_cast(ones) / static_cast(stats.samples); + stats.flip_ratio = stats.samples > 1 ? static_cast(flips) / static_cast(stats.samples - 1) : 0.0; + return stats; +} + +size_t can_message_payload_width(const CanMessageData &message) { + size_t width = 0; + for (const CanFrameSample &sample : message.samples) { + width = std::max(width, sample.data.size()); + } + return width; +} + +size_t cabana_signal_byte_count(const CabanaMessageSummary &message) { + size_t width = 0; + for (const CabanaSignalSummary &signal : message.signals) { + if (!signal.has_bit_range) { + continue; + } + width = std::max(width, static_cast(std::max(signal.msb / 8, signal.lsb / 8) + 1)); + } + return width; +} + +BinaryMatrixLayout build_binary_matrix_layout(const CabanaMessageSummary &message, size_t byte_count) { + BinaryMatrixLayout layout; + layout.byte_count = byte_count; + layout.cell_signals.resize(byte_count * 8); + layout.is_msb.assign(byte_count * 8, false); + layout.is_lsb.assign(byte_count * 8, false); + for (size_t i = 0; i < message.signals.size(); ++i) { + const CabanaSignalSummary &signal = message.signals[i]; + if (!signal.has_bit_range) { + continue; + } + for (size_t byte = 0; byte < byte_count; ++byte) { + for (int bit = 0; bit < 8; ++bit) { + if (signal_contains_bit(signal, byte, bit)) { + layout.cell_signals[byte * 8 + static_cast(bit)].push_back(static_cast(i)); + } + } + } + const size_t msb_byte = static_cast(signal.msb / 8); + const size_t lsb_byte = static_cast(signal.lsb / 8); + if (msb_byte < byte_count) { + layout.is_msb[msb_byte * 8 + static_cast(signal.msb & 7)] = true; + } + if (lsb_byte < byte_count) { + layout.is_lsb[lsb_byte * 8 + static_cast(signal.lsb & 7)] = true; + } + } + for (std::vector &signals : layout.cell_signals) { + std::stable_sort(signals.begin(), signals.end(), [&](int a, int b) { + return message.signals[static_cast(a)].size > message.signals[static_cast(b)].size; + }); + if (signals.size() > 1) { + ++layout.overlapping_cells; + } + } + return layout; +} + +bool cell_has_signal(const BinaryMatrixLayout &layout, int byte_index, int bit_index, int signal_index) { + if (byte_index < 0 || bit_index < 0 || bit_index > 7) { + return false; + } + if (static_cast(byte_index) >= layout.byte_count) { + return false; + } + const std::vector &signals = layout.cell_signals[static_cast(byte_index) * 8 + static_cast(bit_index)]; + return std::find(signals.begin(), signals.end(), signal_index) != signals.end(); +} + +std::vector compute_bit_flip_heat(const CanMessageData &message, + size_t byte_count, + bool live_mode, + size_t tracker_index) { + std::vector heat(byte_count * 8, 0.0f); + if (message.samples.size() < 2 || byte_count == 0) { + return heat; + } + + size_t begin = 0; + size_t end = message.samples.size(); + if (live_mode) { + end = std::min(message.samples.size(), tracker_index + 1); + const size_t window = 96; + begin = end > window ? end - window : 0; + } + if (end <= begin + 1) { + return heat; + } + + std::vector flip_counts(byte_count * 8, 0); + uint32_t max_count = 1; + std::string prev = message.samples[begin].data; + for (size_t i = begin + 1; i < end; ++i) { + const std::string ¤t = message.samples[i].data; + for (size_t byte = 0; byte < byte_count; ++byte) { + const uint8_t before = byte < prev.size() ? static_cast(prev[byte]) : 0; + const uint8_t after = byte < current.size() ? static_cast(current[byte]) : 0; + const uint8_t diff = before ^ after; + if (diff == 0) { + continue; + } + for (int bit = 0; bit < 8; ++bit) { + if ((diff & (1u << bit)) == 0) { + continue; + } + uint32_t &count = flip_counts[byte * 8 + static_cast(bit)]; + ++count; + max_count = std::max(max_count, count); + } + } + prev = current; + } + + for (size_t i = 0; i < flip_counts.size(); ++i) { + if (flip_counts[i] == 0) { + continue; + } + const float frac = static_cast(flip_counts[i]) / static_cast(max_count); + heat[i] = std::sqrt(frac); + } + return heat; +} + +bool signal_charted(const UiState &state, std::string_view path) { + return std::find(state.cabana.chart_signal_paths.begin(), state.cabana.chart_signal_paths.end(), path) + != state.cabana.chart_signal_paths.end(); +} + +ImU32 signal_fill_color(size_t index, float alpha_scale, bool emphasized) { + const auto &rgb = kSignalHighlightColors[index % kSignalHighlightColors.size()]; + const float alpha = emphasized ? std::clamp(0.34f + alpha_scale * 0.38f, 0.34f, 0.78f) + : std::clamp(0.14f + alpha_scale * 0.28f, 0.14f, 0.48f); + return ImGui::GetColorU32(color_rgb(rgb, alpha)); +} + +ImU32 signal_border_color(size_t index, bool emphasized) { + const auto &rgb = kSignalHighlightColors[index % kSignalHighlightColors.size()]; + return ImGui::GetColorU32(color_rgb(rgb[0], rgb[1], rgb[2], emphasized ? 0.95f : 0.78f)); +} + +void draw_cell_hatching(ImDrawList *draw, const ImRect &rect, ImU32 color, float spacing) { + for (float x = rect.Min.x - rect.GetHeight(); x < rect.Max.x; x += spacing) { + const ImVec2 a(std::max(rect.Min.x, x), std::min(rect.Max.y, rect.Min.y + (rect.Min.x - x) + rect.GetHeight())); + const ImVec2 b(std::min(rect.Max.x, x + rect.GetHeight()), std::max(rect.Min.y, rect.Max.y - (rect.Max.x - x))); + draw->AddLine(a, b, color, 1.0f); + } +} + +bool signal_contains_bit(const CabanaSignalSummary &signal, size_t byte_index, int bit_index) { + if (!signal.has_bit_range || bit_index < 0 || bit_index > 7) { + return false; + } + const int msb_byte = signal.msb / 8; + const int lsb_byte = signal.lsb / 8; + if (msb_byte == lsb_byte) { + return static_cast(byte_index) == msb_byte + && bit_index >= (signal.lsb & 7) + && bit_index <= (signal.msb & 7); + } + for (int i = msb_byte, step = signal.is_little_endian ? -1 : 1;; i += step) { + const int hi = i == msb_byte ? (signal.msb & 7) : 7; + const int lo = i == lsb_byte ? (signal.lsb & 7) : 0; + if (static_cast(byte_index) == i && bit_index >= lo && bit_index <= hi) { + return true; + } + if (i == lsb_byte) { + return false; + } + } +} + +std::vector> highlighted_signals(const CabanaMessageSummary &message, const UiState &state) { + std::vector> out; + for (const std::string &path : state.cabana.chart_signal_paths) { + for (size_t i = 0; i < message.signals.size(); ++i) { + const CabanaSignalSummary &signal = message.signals[i]; + if (signal.path != path || !signal.has_bit_range) { + continue; + } + out.push_back({&signal, signal_fill_color(i, 0.5f, true)}); + break; + } + } + return out; +} + +bool cabana_bit_selected(const UiState &state, size_t byte_index, int bit_index) { + return state.cabana.has_bit_selection + && state.cabana.selected_bit_byte == static_cast(byte_index) + && state.cabana.selected_bit_index == bit_index; +} + +std::vector selected_bit_signals(const CabanaMessageSummary &message, const UiState &state) { + std::vector out; + if (!state.cabana.has_bit_selection) { + return out; + } + for (const CabanaSignalSummary &signal : message.signals) { + if (signal_contains_bit(signal, + static_cast(state.cabana.selected_bit_byte), + state.cabana.selected_bit_index)) { + out.push_back(&signal); + } + } + return out; +} + +const CabanaSignalSummary *find_signal_by_path(const CabanaMessageSummary &message, std::string_view path) { + auto it = std::find_if(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) { + return signal.path == path; + }); + return it == message.signals.end() ? nullptr : &*it; +} + +const CabanaSignalSummary *topmost_signal_at_cell(const CabanaMessageSummary &message, + const BinaryMatrixLayout &layout, + size_t byte_index, + int bit_index) { + if (byte_index >= layout.byte_count || bit_index < 0 || bit_index > 7) { + return nullptr; + } + const std::vector &signals = layout.cell_signals[byte_index * 8 + static_cast(bit_index)]; + return signals.empty() ? nullptr : &message.signals[static_cast(signals.back())]; +} + +const CabanaSignalSummary *resize_signal_at_cell(const CabanaMessageSummary &message, + const BinaryMatrixLayout &layout, + size_t byte_index, + int bit_index) { + if (byte_index >= layout.byte_count || bit_index < 0 || bit_index > 7) { + return nullptr; + } + const int physical_bit = static_cast(byte_index) * 8 + bit_index; + const std::vector &signals = layout.cell_signals[byte_index * 8 + static_cast(bit_index)]; + for (int signal_index : signals) { + const CabanaSignalSummary &signal = message.signals[static_cast(signal_index)]; + if (signal.has_bit_range && (physical_bit == signal.msb || physical_bit == signal.lsb)) { + return &signal; + } + } + return nullptr; +} + +std::vector find_similar_bits_from_snapshot(const std::vector &messages, + const std::vector &can_messages, + const CabanaMessageSummary &source_message, + const CanMessageData &source_data, + size_t source_byte, + int source_bit) { + const BitBehaviorStats target = bit_behavior_stats(source_data, source_byte, source_bit); + std::vector matches; + for (const CabanaMessageSummary &message : messages) { + const std::optional service = parse_can_service_kind(message.service); + if (!service.has_value()) continue; + const CanMessageData key{.id = CanMessageId{*service, static_cast(message.bus), message.address}}; + auto it = std::lower_bound(can_messages.begin(), can_messages.end(), key, [](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); + }); + if (it == can_messages.end() + || it->id.service != key.id.service + || it->id.bus != key.id.bus + || it->id.address != key.id.address + || it->samples.size() < 2) { + continue; + } + for (size_t byte = 0; byte < can_message_payload_width(*it); ++byte) { + for (int bit = 0; bit < 8; ++bit) { + if (message.root_path == source_message.root_path + && static_cast(byte) == static_cast(source_byte) + && bit == source_bit) { + continue; + } + const BitBehaviorStats stats = bit_behavior_stats(*it, byte, bit); + if (stats.samples < 2) continue; + const double ones_diff = std::abs(stats.ones_ratio - target.ones_ratio); + const double flip_diff = std::abs(stats.flip_ratio - target.flip_ratio); + matches.push_back({ + .message_root = message.root_path, + .label = message.name, + .bus = message.bus, + .address = message.address, + .byte_index = static_cast(byte), + .bit_index = bit, + .score = ones_diff * 0.65 + flip_diff * 0.35, + .ones_ratio = stats.ones_ratio, + .flip_ratio = stats.flip_ratio, + }); + } + } + } + std::sort(matches.begin(), matches.end(), [](const CabanaSimilarBitMatch &a, const CabanaSimilarBitMatch &b) { + return std::tie(a.score, a.label, a.byte_index, a.bit_index) + < std::tie(b.score, b.label, b.byte_index, b.bit_index); + }); + if (matches.size() > 12) { + matches.resize(12); + } + return matches; +} + +void draw_bit_selection_panel(AppSession *session, const CabanaMessageSummary &message, UiState *state) { + poll_similar_bit_search(state); + if (!state->cabana.has_bit_selection) { + return; + } + app_push_bold_font(); + ImGui::Text("Selected Bit: B%d.%d", state->cabana.selected_bit_byte, state->cabana.selected_bit_index); + app_pop_bold_font(); + ImGui::SameLine(); + if (ImGui::SmallButton("Clear")) { + state->cabana.has_bit_selection = false; + clear_similar_bit_results(state); + return; + } + ImGui::SameLine(); + ImGui::BeginDisabled(state->cabana.similar_bits_loading); + if (ImGui::SmallButton("Find Similar Bits")) { + const CanMessageData *message_data = find_message_data(*session, message); + if (message_data != nullptr) { + state->cabana.similar_bit_matches.clear(); + state->cabana.similar_bits_source_root = message.root_path; + state->cabana.similar_bits_source_byte = state->cabana.selected_bit_byte; + state->cabana.similar_bits_source_bit = state->cabana.selected_bit_index; + state->cabana.similar_bits_loading = true; + const std::vector messages = session->cabana_messages; + const std::vector can_messages = session->route_data.can_messages; + const CanMessageData source_data = *message_data; + const size_t source_byte = static_cast(state->cabana.selected_bit_byte); + const int source_bit = state->cabana.selected_bit_index; + state->cabana.similar_bit_future = std::async(std::launch::async, [messages, can_messages, message, source_data, source_byte, source_bit]() { + return find_similar_bits_from_snapshot(messages, can_messages, message, source_data, source_byte, source_bit); + }); + } + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::SmallButton("Create Signal...")) { + open_cabana_new_signal_editor(*session, + state, + message, + state->cabana.selected_bit_byte, + state->cabana.selected_bit_index); + } + const auto overlaps = selected_bit_signals(message, *state); + if (overlaps.empty()) { + ImGui::TextDisabled("No decoded signals cover this bit."); + } else { + ImGui::TextDisabled("Signals covering this bit:"); + for (size_t i = 0; i < overlaps.size(); ++i) { + if (i > 0) ImGui::SameLine(0.0f, 8.0f); + if (ImGui::SmallButton(overlaps[i]->name.c_str())) { + state->cabana.selected_signal_path = overlaps[i]->path; + } + } + } + + if (state->cabana.similar_bits_loading && similar_bit_results_match_selection(*state)) { + ImGui::Spacing(); + ImGui::TextDisabled("Searching similar bits..."); + } else if (similar_bit_results_match_selection(*state) && !state->cabana.similar_bit_matches.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("Similar bits:"); + if (ImGui::BeginTable("##cabana_similar_bits", 5, + ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_BordersInnerV)) { + ImGui::TableSetupColumn("Message", ImGuiTableColumnFlags_WidthStretch, 2.2f); + ImGui::TableSetupColumn("Bit", ImGuiTableColumnFlags_WidthFixed, 58.0f); + ImGui::TableSetupColumn("Score", ImGuiTableColumnFlags_WidthFixed, 58.0f); + ImGui::TableSetupColumn("1s", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("Flip", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableHeadersRow(); + for (const CabanaSimilarBitMatch &match : state->cabana.similar_bit_matches) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + const std::string label = match.label + "##" + match.message_root + "_" + std::to_string(match.byte_index) + "_" + std::to_string(match.bit_index); + if (ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + select_cabana_message(session, state, match.message_root); + state->cabana.has_bit_selection = true; + state->cabana.selected_bit_byte = match.byte_index; + state->cabana.selected_bit_index = match.bit_index; + } + ImGui::TableNextColumn(); + ImGui::Text("B%d.%d", match.byte_index, match.bit_index); + ImGui::TableNextColumn(); + ImGui::Text("%.3f", match.score); + ImGui::TableNextColumn(); + ImGui::Text("%.0f%%", 100.0 * match.ones_ratio); + ImGui::TableNextColumn(); + ImGui::Text("%.0f%%", 100.0 * match.flip_ratio); + } + ImGui::EndTable(); + } + } + ImGui::Spacing(); +} + +void draw_can_heatmap(const CanMessageData &message, + const std::vector> &highlighted, + double tracker_time) { + const size_t byte_count = can_message_payload_width(message); + if (message.samples.empty() || byte_count == 0) { + return; + } + + app_push_bold_font(); + ImGui::TextUnformatted("History Heatmap"); + app_pop_bold_font(); + ImGui::TextDisabled("aggregated over all frames"); + ImGui::Spacing(); + + const size_t row_count = byte_count * 8; + const float avail_w = ImGui::GetContentRegionAvail().x; + const float label_w = 42.0f; + const float row_h = std::clamp(160.0f / std::max(1.0f, static_cast(row_count)), 10.0f, 16.0f); + const float grid_h = row_h * static_cast(row_count); + const float grid_w = std::max(120.0f, avail_w - label_w - 8.0f); + const int columns = std::max(1, std::min(std::min(220, message.samples.size()), static_cast(grid_w / 4.0f))); + const float cell_w = grid_w / static_cast(columns); + const size_t tracker_index = closest_can_sample_index(message, tracker_time); + + ImGui::InvisibleButton("##cabana_heatmap", ImVec2(label_w + grid_w, grid_h + 4.0f)); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const ImVec2 grid_min(rect.Min.x + label_w, rect.Min.y); + ImDrawList *draw = ImGui::GetWindowDrawList(); + const ImU32 grid_bg = ImGui::GetColorU32(color_rgb(246, 247, 249)); + const ImU32 low = ImGui::GetColorU32(color_rgb(234, 238, 243)); + const ImU32 high = ImGui::GetColorU32(color_rgb(69, 116, 201)); + const ImU32 border = ImGui::GetColorU32(color_rgb(204, 209, 214)); + draw->AddRectFilled(ImVec2(grid_min.x, rect.Min.y), ImVec2(grid_min.x + grid_w, rect.Min.y + grid_h), grid_bg, 4.0f); + + for (size_t row = 0; row < row_count; ++row) { + const size_t byte_index = row / 8; + const int bit_index = 7 - static_cast(row % 8); + const float y0 = rect.Min.y + static_cast(row) * row_h; + const float y1 = y0 + row_h; + if (row % 8 == 0) { + const std::string label = "B" + std::to_string(byte_index); + draw->AddText(ImVec2(rect.Min.x, y0 + 1.0f), ImGui::GetColorU32(color_rgb(92, 100, 112)), label.c_str()); + if (row > 0) { + draw->AddLine(ImVec2(rect.Min.x, y0), ImVec2(grid_min.x + grid_w, y0), border, 1.0f); + } + } + for (int col = 0; col < columns; ++col) { + const size_t start = (message.samples.size() * static_cast(col)) / static_cast(columns); + const size_t end = std::max(start + 1, + (message.samples.size() * static_cast(col + 1)) / static_cast(columns)); + size_t ones = 0; + for (size_t i = start; i < std::min(end, message.samples.size()); ++i) { + ones += can_bit(message.samples[i].data, byte_index, bit_index); + } + const float frac = static_cast(ones) / static_cast(std::max(1, std::min(end, message.samples.size()) - start)); + ImU32 color = mix_color(low, high, frac); + for (const auto &[signal, signal_color] : highlighted) { + if (signal_contains_bit(*signal, byte_index, bit_index)) { + color = mix_color(color, signal_color, 0.65f); + break; + } + } + const float x0 = grid_min.x + static_cast(col) * cell_w; + const float x1 = x0 + cell_w + 0.5f; + draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), color); + } + } + + const float tracker_x = grid_min.x + cell_w * ((static_cast(tracker_index) + 0.5f) * static_cast(columns) + / static_cast(std::max(1, message.samples.size()))); + draw->AddLine(ImVec2(tracker_x, rect.Min.y), ImVec2(tracker_x, rect.Min.y + grid_h), + ImGui::GetColorU32(color_rgb(36, 42, 50, 0.9f)), 2.0f); + draw->AddRect(ImVec2(grid_min.x, rect.Min.y), ImVec2(grid_min.x + grid_w, rect.Min.y + grid_h), border, 4.0f); +} + +void draw_can_frame_view(const CanMessageData &message, + AppSession *session, + const CabanaMessageSummary &summary, + UiState *state, + double tracker_time) { + if (message.samples.empty()) { + draw_cabana_panel_title("Binary View"); + ImGui::TextDisabled("No raw CAN frames available."); + return; + } + const size_t sample_index = closest_can_sample_index(message, tracker_time); + const CanFrameSample &sample = message.samples[sample_index]; + const CanFrameSample *prev = sample_index > 0 ? &message.samples[sample_index - 1] : nullptr; + const size_t byte_count = std::max(can_message_payload_width(message), cabana_signal_byte_count(summary)); + const BinaryMatrixLayout layout = build_binary_matrix_layout(summary, byte_count); + const std::vector heat = compute_bit_flip_heat(message, byte_count, state->cabana.heatmap_live_mode, sample_index); + app_push_bold_font(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Binary"); + app_pop_bold_font(); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("frame %.3fs", sample.mono_time); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("tracker %.3fs", tracker_time); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("%zu bytes", sample.data.size()); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("bus %d", summary.bus); + if (sample.bus_time != 0) { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("bus_time %u", sample.bus_time); + } + if (prev != nullptr) { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("dt %.1f ms", 1000.0 * (sample.mono_time - prev->mono_time)); + } + ImGui::Spacing(); + draw_payload_bytes(sample.data, prev == nullptr ? nullptr : &prev->data); + if (layout.overlapping_cells > 0) { + ImGui::SameLine(0.0f, 12.0f); + ImGui::TextColored(color_rgb(222, 181, 86), "%zu overlap%s", + layout.overlapping_cells, layout.overlapping_cells == 1 ? "" : "s"); + } + ImGui::Spacing(); + + const float footer_reserve = state->cabana.has_bit_selection ? 152.0f : 8.0f; + const float matrix_height = std::max(140.0f, ImGui::GetContentRegionAvail().y - footer_reserve); + const float index_w = 28.0f; + const float hex_w = 36.0f; + const float bit_w = std::max(32.0f, (ImGui::GetContentRegionAvail().x - index_w - hex_w) / 8.0f); + const float grid_w = bit_w * 8.0f; + const float row_h = std::clamp((matrix_height - 4.0f) / std::max(1.0f, static_cast(byte_count)), + 28.0f, + 42.0f); + const float total_h = row_h * static_cast(byte_count); + const ImU32 base_bg = ImGui::GetColorU32(color_rgb(56, 58, 61)); + const ImU32 heat_high = ImGui::GetColorU32(color_rgb(72, 117, 202)); + const ImU32 cell_border = ImGui::GetColorU32(color_rgb(102, 106, 111, 0.72f)); + const ImU32 text_color = ImGui::GetColorU32(color_rgb(219, 223, 228)); + const ImU32 marker_color = ImGui::GetColorU32(color_rgb(175, 180, 186)); + const ImU32 invalid_hatch = ImGui::GetColorU32(color_rgb(126, 131, 139, 0.58f)); + const ImU32 selection_border = ImGui::GetColorU32(color_rgb(232, 236, 241, 0.95f)); + const ImU32 hover_border = ImGui::GetColorU32(color_rgb(232, 236, 241, 0.32f)); + const ImU32 drag_fill = ImGui::GetColorU32(color_rgb(87, 127, 219, 0.18f)); + const ImU32 drag_border = ImGui::GetColorU32(color_rgb(54, 91, 184, 0.95f)); + const bool released_this_frame = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + bool drag_release_handled = false; + ImGui::BeginChild("##cabana_binary_grid", ImVec2(0.0f, matrix_height), false); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 mouse = ImGui::GetIO().MousePos; + const float content_w = index_w + grid_w + hex_w; + ImGui::InvisibleButton("##cabana_binary_grid_area", ImVec2(content_w, total_h)); + const bool area_hovered = ImGui::IsItemHovered(); + const float scroll_y = ImGui::GetScrollY(); + + int hover_byte = -1; + int hover_bit = -1; + const bool tracking_hover = area_hovered || (state->cabana.binary_drag_active && ImGui::IsMouseDown(ImGuiMouseButton_Left)); + if (tracking_hover) { + const float rel_x = mouse.x - (origin.x + index_w); + const float rel_y = mouse.y - origin.y + scroll_y; + if (rel_x >= 0.0f && rel_x < grid_w && rel_y >= 0.0f && rel_y < total_h) { + hover_byte = std::clamp(static_cast(rel_y / row_h), 0, static_cast(byte_count) - 1); + const int col = std::clamp(static_cast(rel_x / bit_w), 0, 7); + hover_bit = 7 - col; + } + } + if (state->cabana.binary_drag_active && hover_byte >= 0 && hover_bit >= 0) { + if (state->cabana.binary_drag_current_byte != hover_byte || state->cabana.binary_drag_current_bit != hover_bit) { + state->cabana.binary_drag_moved = true; + } + state->cabana.binary_drag_current_byte = hover_byte; + state->cabana.binary_drag_current_bit = hover_bit; + } + + const int first_row = std::max(0, static_cast(scroll_y / row_h) - 1); + const int last_row = std::min(static_cast(byte_count), + static_cast((scroll_y + matrix_height) / row_h) + 2); + ImDrawList *draw = ImGui::GetWindowDrawList(); + bool any_bit_hovered = false; + + for (int row = first_row; row < last_row; ++row) { + const float y0 = origin.y + static_cast(row) * row_h - scroll_y; + const float y1 = y0 + row_h; + const bool valid = static_cast(row) < sample.data.size(); + + const ImRect index_cell(ImVec2(origin.x, y0), ImVec2(origin.x + index_w, y1)); + draw->AddRectFilled(index_cell.Min, index_cell.Max, ImGui::GetColorU32(color_rgb(49, 51, 54))); + draw->AddRect(index_cell.Min, index_cell.Max, cell_border); + const std::string label = std::to_string(row); + const ImVec2 label_size = ImGui::CalcTextSize(label.c_str()); + draw->AddText(ImVec2(index_cell.Min.x + (index_cell.GetWidth() - label_size.x) * 0.5f, + index_cell.Min.y + (index_cell.GetHeight() - label_size.y) * 0.5f), + ImGui::GetColorU32(color_rgb(84, 92, 103)), + label.c_str()); + + for (int col = 0; col < 8; ++col) { + const int bit = 7 - col; + const size_t cell_index = static_cast(row) * 8 + static_cast(bit); + const ImRect cell(ImVec2(origin.x + index_w + static_cast(col) * bit_w, y0), + ImVec2(origin.x + index_w + static_cast(col + 1) * bit_w, y1)); + const float heat_alpha = cell_index < heat.size() ? heat[cell_index] : 0.0f; + const std::vector &cell_signals = layout.cell_signals[cell_index]; + const bool hovered = hover_byte == row && hover_bit == bit; + const CabanaSignalSummary *clicked_signal = topmost_signal_at_cell(summary, layout, static_cast(row), bit); + const CabanaSignalSummary *resize_signal = resize_signal_at_cell(summary, layout, static_cast(row), bit); + + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + clear_cabana_binary_drag(state); + state->cabana.binary_drag_active = true; + state->cabana.binary_drag_press_byte = row; + state->cabana.binary_drag_press_bit = bit; + state->cabana.binary_drag_current_byte = row; + state->cabana.binary_drag_current_bit = bit; + if (resize_signal != nullptr) { + const int physical_bit = row * 8 + bit; + const int opposite = physical_bit == resize_signal->lsb ? resize_signal->msb : resize_signal->lsb; + state->cabana.binary_drag_resizing = true; + state->cabana.binary_drag_signal_is_little_endian = resize_signal->is_little_endian; + state->cabana.binary_drag_signal_path = resize_signal->path; + state->cabana.binary_drag_anchor_byte = opposite / 8; + state->cabana.binary_drag_anchor_bit = opposite & 7; + } else { + state->cabana.binary_drag_anchor_byte = row; + state->cabana.binary_drag_anchor_bit = bit; + } + } + + draw->AddRectFilled(cell.Min, cell.Max, mix_color(base_bg, heat_high, heat_alpha * 0.55f)); + + if (valid && !cell_signals.empty()) { + for (int signal_index : cell_signals) { + const CabanaSignalSummary &signal = summary.signals[static_cast(signal_index)]; + const bool emphasized = signal_charted(*state, signal.path); + const bool draw_left = !cell_has_signal(layout, row, bit + 1, signal_index); + const bool draw_right = !cell_has_signal(layout, row, bit - 1, signal_index); + const bool draw_top = !cell_has_signal(layout, row - 1, bit, signal_index); + const bool draw_bottom = !cell_has_signal(layout, row + 1, bit, signal_index); + ImRect inner = cell; + inner.Min.x += draw_left ? 3.0f : 1.0f; + inner.Max.x -= draw_right ? 3.0f : 1.0f; + inner.Min.y += draw_top ? 2.0f : 1.0f; + inner.Max.y -= draw_bottom ? 2.0f : 1.0f; + draw->AddRectFilled(inner.Min, inner.Max, + signal_fill_color(static_cast(signal_index), heat_alpha, emphasized), + 2.0f); + const ImU32 border_color = signal_border_color(static_cast(signal_index), emphasized); + const float thickness = emphasized ? 2.0f : 1.0f; + if (draw_left) draw->AddLine(ImVec2(inner.Min.x, inner.Min.y), ImVec2(inner.Min.x, inner.Max.y), border_color, thickness); + if (draw_right) draw->AddLine(ImVec2(inner.Max.x, inner.Min.y), ImVec2(inner.Max.x, inner.Max.y), border_color, thickness); + if (draw_top) draw->AddLine(ImVec2(inner.Min.x, inner.Min.y), ImVec2(inner.Max.x, inner.Min.y), border_color, thickness); + if (draw_bottom) draw->AddLine(ImVec2(inner.Min.x, inner.Max.y), ImVec2(inner.Max.x, inner.Max.y), border_color, thickness); + } + if (cell_signals.size() > 1) { + const ImVec2 a(cell.Max.x - 10.0f, cell.Min.y + 2.0f); + const ImVec2 b(cell.Max.x - 2.0f, cell.Min.y + 2.0f); + const ImVec2 c(cell.Max.x - 2.0f, cell.Min.y + 10.0f); + draw->AddTriangleFilled(a, b, c, ImGui::GetColorU32(color_rgb(223, 181, 87, 0.82f))); + } + } else if (!valid) { + draw->AddRectFilled(cell.Min, cell.Max, ImGui::GetColorU32(color_rgb(47, 49, 52))); + draw_cell_hatching(draw, cell, invalid_hatch, 7.0f); + } + + if (cabana_drag_selection_contains_bit(*state, static_cast(row), bit)) { + draw->AddRectFilled(cell.Min, cell.Max, drag_fill); + const bool draw_left = !cabana_drag_selection_has_neighbor(*state, row, bit + 1); + const bool draw_right = !cabana_drag_selection_has_neighbor(*state, row, bit - 1); + const bool draw_top = !cabana_drag_selection_has_neighbor(*state, row - 1, bit); + const bool draw_bottom = !cabana_drag_selection_has_neighbor(*state, row + 1, bit); + if (draw_left) draw->AddLine(ImVec2(cell.Min.x, cell.Min.y), ImVec2(cell.Min.x, cell.Max.y), drag_border, 2.0f); + if (draw_right) draw->AddLine(ImVec2(cell.Max.x, cell.Min.y), ImVec2(cell.Max.x, cell.Max.y), drag_border, 2.0f); + if (draw_top) draw->AddLine(ImVec2(cell.Min.x, cell.Min.y), ImVec2(cell.Max.x, cell.Min.y), drag_border, 2.0f); + if (draw_bottom) draw->AddLine(ImVec2(cell.Min.x, cell.Max.y), ImVec2(cell.Max.x, cell.Max.y), drag_border, 2.0f); + } + + draw->AddRect(cell.Min, cell.Max, cell_border); + if (valid) { + app_push_mono_font(); + const char bit_text[2] = {static_cast(can_bit(sample.data, static_cast(row), bit) ? '1' : '0'), '\0'}; + const ImVec2 text_size = ImGui::CalcTextSize(bit_text); + draw->AddText(ImGui::GetFont(), + ImGui::GetFontSize(), + ImVec2(cell.Min.x + (cell.GetWidth() - text_size.x) * 0.5f, + cell.Min.y + (cell.GetHeight() - text_size.y) * 0.5f - 1.0f), + text_color, + bit_text); + app_pop_mono_font(); + } + if (layout.is_msb[cell_index] || layout.is_lsb[cell_index]) { + const char marker[2] = {layout.is_msb[cell_index] ? 'M' : 'L', '\0'}; + draw->AddText(ImVec2(cell.Max.x - 11.0f, cell.Max.y - 14.0f), marker_color, marker); + } + if (cabana_bit_selected(*state, static_cast(row), bit)) { + draw->AddRect(cell.Min, cell.Max, selection_border, 0.0f, 0, 2.0f); + } else if (hovered) { + draw->AddRect(cell.Min, cell.Max, hover_border, 0.0f, 0, 1.0f); + } + if (hovered) { + any_bit_hovered = true; + if (clicked_signal != nullptr && !state->cabana.binary_drag_active) { + ImGui::SetTooltip("%s\nstart_bit %d size %d lsb %d msb %d", + clicked_signal->name.c_str(), + clicked_signal->start_bit, + clicked_signal->size, + clicked_signal->lsb, + clicked_signal->msb); + } + } + } + + float byte_heat = 0.0f; + for (int bit = 0; bit < 8; ++bit) { + byte_heat = std::max(byte_heat, heat[static_cast(row) * 8 + static_cast(bit)]); + } + const ImRect hex_cell(ImVec2(origin.x + index_w + grid_w, y0), + ImVec2(origin.x + index_w + grid_w + hex_w, y1)); + draw->AddRectFilled(hex_cell.Min, hex_cell.Max, mix_color(base_bg, heat_high, byte_heat * 0.5f)); + draw->AddRect(hex_cell.Min, hex_cell.Max, cell_border); + if (valid) { + app_push_mono_font(); + char hex[4]; + std::snprintf(hex, sizeof(hex), "%02X", static_cast(sample.data[static_cast(row)])); + const ImVec2 text_size = ImGui::CalcTextSize(hex); + draw->AddText(ImGui::GetFont(), + ImGui::GetFontSize(), + ImVec2(hex_cell.Min.x + (hex_cell.GetWidth() - text_size.x) * 0.5f, + hex_cell.Min.y + (hex_cell.GetHeight() - text_size.y) * 0.5f - 1.0f), + text_color, + hex); + app_pop_mono_font(); + } else { + draw_cell_hatching(draw, hex_cell, invalid_hatch, 7.0f); + } + } + + ImGui::EndChild(); + if (released_this_frame && state->cabana.binary_drag_active && !drag_release_handled) { + if (state->cabana.binary_drag_current_byte >= 0 && state->cabana.binary_drag_current_bit >= 0) { + const CabanaSignalSummary *release_signal = + topmost_signal_at_cell(summary, + layout, + static_cast(state->cabana.binary_drag_current_byte), + state->cabana.binary_drag_current_bit); + queue_binary_drag_apply(session, + summary, + state, + state->cabana.binary_drag_current_byte, + state->cabana.binary_drag_current_bit, + release_signal); + } + clear_cabana_binary_drag(state); + } else if (!any_bit_hovered && released_this_frame && state->cabana.binary_drag_active) { + clear_cabana_binary_drag(state); + } + + ImGui::Spacing(); + draw_bit_selection_panel(session, summary, state); +} + +bool message_has_overlaps(const CabanaMessageSummary &message, const CanMessageData *message_data) { + const size_t byte_count = std::max(message_data == nullptr ? 0 : can_message_payload_width(*message_data), + cabana_signal_byte_count(message)); + if (byte_count == 0) { + return false; + } + return build_binary_matrix_layout(message, byte_count).overlapping_cells > 0; +} + +void draw_detail_toolbar(AppSession *session, + UiState *state, + const CabanaMessageSummary &message, + const CanMessageData *message_data) { + const std::string meta = message.service + " bus " + std::to_string(message.bus) + + (message.has_address ? " " + format_can_address(message.address) : std::string()); + const std::string dbc_text = session->route_data.dbc_name.empty() ? "DBC: Auto / none" + : "DBC: " + session->route_data.dbc_name; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 6.0f)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleColor(ImGuiCol_Border, cabana_border_color()); + const bool compact = ImGui::GetContentRegionAvail().x < 760.0f; + ImGui::BeginChild("##cabana_detail_toolbar", ImVec2(0.0f, compact ? 68.0f : 40.0f), true); + app_push_bold_font(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(message.name.c_str()); + app_pop_bold_font(); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("%s", meta.c_str()); + if (message.frequency_hz > 0.0) { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("%.1f Hz", message.frequency_hz); + } + if (message_data != nullptr) { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("%zu frames", message_data->samples.size()); + } + + if (compact) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 4.0f); + } else { + ImGui::SameLine(std::max(340.0f, ImGui::GetWindowContentRegionMax().x * 0.46f)); + } + ImGui::TextUnformatted("Heatmap:"); + ImGui::SameLine(0.0f, 6.0f); + if (ImGui::RadioButton("Live", state->cabana.heatmap_live_mode)) { + state->cabana.heatmap_live_mode = true; + } + ImGui::SameLine(0.0f, 8.0f); + if (ImGui::RadioButton("All", !state->cabana.heatmap_live_mode)) { + state->cabana.heatmap_live_mode = false; + } + ImGui::SameLine(0.0f, 14.0f); + draw_cabana_toolbar_button("Edit DBC...", true, [&]() { + state->dbc_editor.open = true; + state->dbc_editor.loaded = false; + }); + ImGui::SameLine(0.0f, 6.0f); + draw_cabana_toolbar_button("Export Raw CSV", message_data != nullptr, [&]() { + fs::path output_path; + std::string error; + if (export_raw_can_csv(*session, message, &error, &output_path)) { + state->status_text = "Exported raw CSV " + output_path.filename().string(); + } else { + state->status_text = error; + } + }); + ImGui::SameLine(0.0f, 6.0f); + draw_cabana_toolbar_button("Export Decoded CSV", message_data != nullptr, [&]() { + fs::path output_path; + std::string error; + if (export_decoded_can_csv(*session, message, &error, &output_path)) { + state->status_text = "Exported decoded CSV " + output_path.filename().string(); + } else { + state->status_text = error; + } + }); + if (!compact) { + const float right_w = ImGui::CalcTextSize(dbc_text.c_str()).x + 10.0f; + ImGui::SameLine(std::max(0.0f, ImGui::GetWindowContentRegionMax().x - right_w)); + } else { + ImGui::SameLine(0.0f, 12.0f); + } + ImGui::TextDisabled("%s", dbc_text.c_str()); + ImGui::EndChild(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + +void draw_messages_panel(AppSession *session, UiState *state) { + const std::string name_filter = trim_copy(state->cabana.message_filter.data()); + const std::string bus_filter = trim_copy(state->cabana.message_bus_filter.data()); + const std::string addr_filter = trim_copy(state->cabana.message_addr_filter.data()); + const std::string node_filter = trim_copy(state->cabana.message_node_filter.data()); + const std::string freq_filter = trim_copy(state->cabana.message_freq_filter.data()); + const std::string count_filter = trim_copy(state->cabana.message_count_filter.data()); + const std::string bytes_filter = trim_copy(state->cabana.message_bytes_filter.data()); + + std::vector filtered_indices; + filtered_indices.reserve(session->cabana_messages.size()); + size_t filtered_signal_count = 0; + size_t filtered_dbc_count = 0; + for (int i = 0; i < static_cast(session->cabana_messages.size()); ++i) { + const CabanaMessageSummary &message = session->cabana_messages[static_cast(i)]; + const CanMessageData *message_data = find_message_data(*session, message); + if (state->cabana.suppress_defined_signals && !message.signals.empty()) { + continue; + } + if (!cabana_message_matches_filters(message, name_filter, bus_filter, addr_filter, node_filter, + freq_filter, count_filter, bytes_filter, message_data)) { + continue; + } + filtered_indices.push_back(i); + filtered_signal_count += message.signals.size(); + if (message.dbc_size > 0 || !message.node.empty() || !message.signals.empty()) { + ++filtered_dbc_count; + } + } + + char title[160]; + std::snprintf(title, sizeof(title), "%zu Messages (%zu DBC Messages, %zu Signals)", + filtered_indices.size(), filtered_dbc_count, filtered_signal_count); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + ImGui::BeginChild("##cabana_messages_header", ImVec2(0.0f, 34.0f), false, ImGuiWindowFlags_NoScrollbar); + ImGui::TextUnformatted(title); + const float clear_w = 46.0f; + const float checkbox_w = 126.0f; + const float target_x = std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - clear_w - checkbox_w - 16.0f); + ImGui::SameLine(target_x); + if (ImGui::SmallButton("Clear")) { + state->cabana.message_filter[0] = '\0'; + state->cabana.message_bus_filter[0] = '\0'; + state->cabana.message_addr_filter[0] = '\0'; + state->cabana.message_node_filter[0] = '\0'; + state->cabana.message_freq_filter[0] = '\0'; + state->cabana.message_count_filter[0] = '\0'; + state->cabana.message_bytes_filter[0] = '\0'; + } + ImGui::SameLine(0.0f, 8.0f); + ImGui::Checkbox("Suppress Signals", &state->cabana.suppress_defined_signals); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::Spacing(); + + if (session->cabana_messages.empty()) { + ImGui::TextDisabled("No CAN messages in this route."); + return; + } + if (filtered_indices.empty()) { + ImGui::TextDisabled("No messages match the current filters."); + return; + } + + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(4.0f, 2.0f)); + if (ImGui::BeginTable("##cabana_messages", 7, + ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollX | + ImGuiTableFlags_ScrollY | + ImGuiTableFlags_Borders | + ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingStretchProp, + ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 2); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoHide, 1.8f); + ImGui::TableSetupColumn("Bus", ImGuiTableColumnFlags_WidthFixed, 46.0f); + ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Node", ImGuiTableColumnFlags_WidthFixed, 84.0f); + ImGui::TableSetupColumn("Hz", ImGuiTableColumnFlags_WidthFixed, 54.0f); + ImGui::TableSetupColumn("Count", ImGuiTableColumnFlags_WidthFixed, 64.0f); + ImGui::TableSetupColumn("Bytes", ImGuiTableColumnFlags_WidthStretch, 1.2f); + ImGui::TableHeadersRow(); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_name", "Filter", state->cabana.message_filter.data(), + state->cabana.message_filter.size()); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_bus", "Bus", state->cabana.message_bus_filter.data(), + state->cabana.message_bus_filter.size()); + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_addr", "Addr", state->cabana.message_addr_filter.data(), + state->cabana.message_addr_filter.size()); + ImGui::TableSetColumnIndex(3); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_node", "Node", state->cabana.message_node_filter.data(), + state->cabana.message_node_filter.size()); + ImGui::TableSetColumnIndex(4); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_freq", "Hz", state->cabana.message_freq_filter.data(), + state->cabana.message_freq_filter.size()); + ImGui::TableSetColumnIndex(5); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_count", "Count", state->cabana.message_count_filter.data(), + state->cabana.message_count_filter.size()); + ImGui::TableSetColumnIndex(6); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##cabana_filter_bytes", "Bytes", state->cabana.message_bytes_filter.data(), + state->cabana.message_bytes_filter.size()); + + ImGuiListClipper clipper; + clipper.Begin(static_cast(filtered_indices.size())); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const CabanaMessageSummary &message = session->cabana_messages[static_cast(filtered_indices[static_cast(row)])]; + const bool selected = state->cabana.selected_message_root == message.root_path; + const std::string address = message.has_address ? format_can_address(message.address) : std::string("--"); + + ImGui::TableNextRow(0, 22.0f); + ImGui::TableSetColumnIndex(0); + if (ImGui::Selectable((message.name + "##" + message.root_path).c_str(), selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + select_cabana_message(session, state, message.root_path); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", message.root_path.c_str()); + } + + ImGui::TableSetColumnIndex(1); + const std::string bus_label = message.service == "sendcan" + ? "S" + std::to_string(message.bus) + : std::to_string(message.bus); + ImGui::TextUnformatted(bus_label.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(address.c_str()); + ImGui::TableSetColumnIndex(3); + if (message.node.empty()) ImGui::TextDisabled("-"); + else ImGui::TextUnformatted(message.node.c_str()); + ImGui::TableSetColumnIndex(4); + if (message.frequency_hz >= 0.95) ImGui::Text("%.0f", message.frequency_hz); + else if (message.frequency_hz > 0.0) ImGui::Text("%.2f", message.frequency_hz); + else ImGui::TextDisabled("-"); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%zu", message.sample_count); + ImGui::TableSetColumnIndex(6); + const CanMessageData *message_data = find_message_data(*session, message); + if (message_data != nullptr && !message_data->samples.empty()) { + const size_t current_index = state->has_tracker_time + ? closest_can_sample_index(*message_data, state->tracker_time) + : (message_data->samples.size() - 1); + const CanFrameSample &last = message_data->samples[current_index]; + const CanFrameSample *prev = current_index > 0 ? &message_data->samples[current_index - 1] : nullptr; + draw_payload_preview_boxes(("##msg_bytes_" + message.root_path).c_str(), + last.data, + prev == nullptr ? nullptr : &prev->data, + std::max(72.0f, ImGui::GetColumnWidth() - 10.0f)); + } else { + ImGui::TextDisabled("-"); + } + } + } + ImGui::EndTable(); + } + ImGui::PopStyleVar(); +} + +void draw_logs_toolbar(const AppSession &session, + UiState *state, + const CabanaMessageSummary &message, + bool can_show_signal_mode, + bool show_signal_mode) { + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + const bool compact = ImGui::GetContentRegionAvail().x < 560.0f; + ImGui::BeginChild("##cabana_logs_toolbar", ImVec2(0.0f, compact ? 56.0f : 34.0f), false, ImGuiWindowFlags_NoScrollbar); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Display:"); + ImGui::SameLine(0.0f, 6.0f); + ImGui::BeginDisabled(!can_show_signal_mode); + if (ImGui::RadioButton("Signal", show_signal_mode)) { + state->cabana.logs_hex_mode = false; + } + ImGui::EndDisabled(); + ImGui::SameLine(0.0f, 8.0f); + if (ImGui::RadioButton("Hex", !show_signal_mode)) { + state->cabana.logs_hex_mode = true; + } + + const std::string signal_path(active_signal_path(*state)); + if (can_show_signal_mode) { + const size_t slash = signal_path.find_last_of('/'); + const std::string signal_name = slash == std::string::npos ? signal_path : signal_path.substr(slash + 1); + ImGui::SameLine(0.0f, 12.0f); + ImGui::TextDisabled("%s", signal_name.c_str()); + if (show_signal_mode) { + static constexpr const char *kOps[] = {">", "=", "!=", "<"}; + if (compact) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 4.0f); + } else { + ImGui::SameLine(0.0f, 10.0f); + } + ImGui::SetNextItemWidth(54.0f); + ImGui::Combo("##cabana_logs_cmp", &state->cabana.logs_filter_compare, kOps, IM_ARRAYSIZE(kOps)); + ImGui::SameLine(0.0f, 6.0f); + ImGui::SetNextItemWidth(96.0f); + ImGui::InputTextWithHint("##cabana_logs_value", "value", state->cabana.logs_filter_value.data(), + state->cabana.logs_filter_value.size()); + } + } + + const float export_w = 76.0f; + ImGui::SameLine(std::max(0.0f, ImGui::GetWindowContentRegionMax().x - export_w)); + if (ImGui::SmallButton("Export")) { + fs::path output_path; + std::string error; + const bool ok = show_signal_mode ? export_decoded_can_csv(session, message, &error, &output_path) + : export_raw_can_csv(session, message, &error, &output_path); + state->status_text = ok ? ("Exported " + output_path.filename().string()) : error; + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::Spacing(); +} + +void draw_message_history(const AppSession &session, UiState *state, const CabanaMessageSummary &message) { + const CanMessageData *message_data = find_message_data(session, message); + if (message_data == nullptr || message_data->samples.empty()) { + ImGui::TextDisabled("No frame history available."); + return; + } + + const std::string signal_path(active_signal_path(*state)); + const RouteSeries *series = signal_path.empty() ? nullptr : app_find_route_series(session, signal_path); + const auto format_it = signal_path.empty() ? session.route_data.series_formats.end() : session.route_data.series_formats.find(signal_path); + const auto enum_it = signal_path.empty() ? session.route_data.enum_info.end() : session.route_data.enum_info.find(signal_path); + const size_t current_index = closest_can_sample_index(*message_data, state->tracker_time); + const bool can_show_signal_mode = series != nullptr; + const bool show_signal_mode = can_show_signal_mode && !state->cabana.logs_hex_mode; + + draw_logs_toolbar(session, state, message, can_show_signal_mode, show_signal_mode); + + const bool have_filter = show_signal_mode && state->cabana.logs_filter_value[0] != '\0'; + const double filter_value = have_filter ? std::strtod(state->cabana.logs_filter_value.data(), nullptr) : 0.0; + auto passes_filter = [&](double value) { + if (!have_filter) return true; + switch (state->cabana.logs_filter_compare) { + case 0: return value > filter_value; + case 1: return value == filter_value; + case 2: return value != filter_value; + case 3: return value < filter_value; + default: return true; + } + }; + + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(5.0f, 3.0f)); + const int columns = (!show_signal_mode && series != nullptr) ? 4 : 3; + if (ImGui::BeginTable("##cabana_history", columns, + ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | + ImGuiTableFlags_SizingStretchProp | + ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_BordersOuterH | + ImGuiTableFlags_NoPadOuterX, + ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 96.0f); + ImGui::TableSetupColumn("dt", ImGuiTableColumnFlags_WidthFixed, 72.0f); + if (show_signal_mode) { + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch, 1.0f); + } else { + ImGui::TableSetupColumn("Data", ImGuiTableColumnFlags_WidthStretch, 1.0f); + } + if (!show_signal_mode && series != nullptr) { + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 108.0f); + } + ImGui::TableHeadersRow(); + + ImGuiListClipper clipper; + clipper.Begin(static_cast(message_data->samples.size())); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + const CanFrameSample &sample = message_data->samples[static_cast(i)]; + const CanFrameSample *prev = i > 0 ? &message_data->samples[static_cast(i - 1)] : nullptr; + std::optional value; + if (series != nullptr) { + value = app_sample_xy_value_at_time(series->times, series->values, false, sample.mono_time); + if (show_signal_mode && (!value.has_value() || !passes_filter(*value))) { + continue; + } + } + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + const bool selected = static_cast(i) == current_index; + char label[32]; + std::snprintf(label, sizeof(label), "%.3f##frame_%d", sample.mono_time, i); + if (ImGui::Selectable(label, selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + state->tracker_time = sample.mono_time; + state->has_tracker_time = true; + } + + ImGui::TableNextColumn(); + if (prev != nullptr) { + ImGui::Text("%.1fms", 1000.0 * (sample.mono_time - prev->mono_time)); + } else { + ImGui::TextDisabled("--"); + } + + ImGui::TableNextColumn(); + if (show_signal_mode) { + if (value.has_value()) { + if (format_it != session.route_data.series_formats.end()) { + ImGui::TextUnformatted(format_display_value(*value, + format_it->second, + enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second).c_str()); + } else { + ImGui::Text("%.4f", *value); + } + } else { + ImGui::TextDisabled("--"); + } + } else { + draw_payload_bytes(sample.data, prev == nullptr ? nullptr : &prev->data); + } + + if (!show_signal_mode && series != nullptr) { + ImGui::TableNextColumn(); + if (value.has_value()) { + if (format_it != session.route_data.series_formats.end()) { + ImGui::TextUnformatted(format_display_value(*value, + format_it->second, + enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second).c_str()); + } else { + ImGui::Text("%.4f", *value); + } + } else { + ImGui::TextDisabled("--"); + } + } + } + } + ImGui::EndTable(); + } + ImGui::PopStyleVar(); +} + +float preferred_binary_view_height(const CanMessageData &message, const CabanaMessageSummary &summary, const UiState &state) { + const size_t byte_count = std::max(can_message_payload_width(message), cabana_signal_byte_count(summary)); + const float row_h = 34.0f; + const float header_h = 88.0f; + const float footer_h = state.cabana.has_bit_selection ? 148.0f : 8.0f; + return header_h + row_h * static_cast(std::max(1, byte_count)) + footer_h; +} + +void draw_detail_panel(AppSession *session, UiState *state, const CabanaMessageSummary &message) { + const CanMessageData *message_data = find_message_data(*session, message); + draw_cabana_message_tabs(session, state); + draw_detail_toolbar(session, state, message, message_data); + std::vector warnings; + if (message_has_overlaps(message, message_data)) { + warnings.push_back("One or more decoded signals overlap in the binary view."); + } + if (message.dbc_size > 0 && message_data != nullptr && !message_data->samples.empty() + && static_cast(message_data->samples.back().data.size()) != message.dbc_size) { + warnings.push_back("Message size does not match the active DBC definition."); + } + draw_cabana_warning_banner(warnings); + ImGui::Spacing(); + + const float bottom_tabs_h = 30.0f; + const float detail_content_h = std::max(0.0f, ImGui::GetContentRegionAvail().y - bottom_tabs_h); + const float split_span = std::max(1.0f, detail_content_h - kSplitterThickness); + const float min_top_frac = kMinTopHeight / split_span; + const float min_bottom_frac = kMinBottomHeight / split_span; + state->cabana.layout_center_top_frac = std::clamp(state->cabana.layout_center_top_frac, + min_top_frac, + std::max(min_top_frac, 1.0f - min_bottom_frac)); + float top_height = std::floor(split_span * state->cabana.layout_center_top_frac); + top_height = std::clamp(top_height, kMinTopHeight, std::max(kMinTopHeight, detail_content_h - kMinBottomHeight - kSplitterThickness)); + if (state->cabana.detail_tab == 0 && state->cabana.detail_top_auto_fit && message_data != nullptr) { + top_height = std::clamp(preferred_binary_view_height(*message_data, message, *state), + kMinTopHeight, + std::max(kMinTopHeight, detail_content_h - kMinBottomHeight - kSplitterThickness)); + state->cabana.layout_center_top_frac = top_height / split_span; + } + + ImGui::BeginChild("##cabana_detail_content", ImVec2(0.0f, detail_content_h), false); + if (state->cabana.detail_tab == 0) { + ImGui::BeginChild("##cabana_msg_top", ImVec2(0.0f, top_height), false); + if (message_data != nullptr) { + draw_can_frame_view(*message_data, session, message, state, state->tracker_time); + } else { + draw_empty_panel("Binary View", "No raw CAN frames available for this message."); + } + ImGui::EndChild(); + if (draw_horizontal_splitter("##cabana_detail_splitter", + ImGui::GetContentRegionAvail().x, + kMinTopHeight, + std::max(kMinTopHeight, ImGui::GetContentRegionAvail().y - kMinBottomHeight), + &top_height)) { + state->cabana.detail_top_auto_fit = false; + state->cabana.layout_center_top_frac = std::clamp(top_height / split_span, + min_top_frac, + std::max(min_top_frac, 1.0f - min_bottom_frac)); + } + ImGui::BeginChild("##cabana_signals_bottom", ImVec2(0.0f, 0.0f), false); + draw_signal_panel(session, state, message); + ImGui::EndChild(); + } else { + if (message_data != nullptr) { + draw_can_heatmap(*message_data, highlighted_signals(message, *state), state->tracker_time); + ImGui::Spacing(); + } + draw_message_history(*session, state, message); + } + ImGui::EndChild(); + draw_cabana_detail_tab_strip(state); +} + +void draw_video_panel(AppSession *session, UiState *state, float height) { + const auto &views = camera_view_specs(); + std::vector available_views; + available_views.reserve(views.size()); + for (const CameraViewSpec &spec : views) { + if (!(session->route_data.*(spec.route_member)).entries.empty()) { + available_views.push_back(&spec); + } + } + + draw_cabana_panel_title("Video"); + + if (available_views.empty()) { + ImGui::BeginChild("##cabana_video_empty", ImVec2(0.0f, height), false); + ImGui::TextDisabled("No camera streams available."); + ImGui::EndChild(); + return; + } + + if (std::none_of(available_views.begin(), available_views.end(), [&](const CameraViewSpec *spec) { + return spec->view == state->cabana.camera_view; + })) { + state->cabana.camera_view = available_views.front()->view; + } + + auto short_label = [](const CameraViewSpec &spec) { + switch (spec.view) { + case CameraViewKind::Road: return "Road"; + case CameraViewKind::Driver: return "Driver"; + case CameraViewKind::WideRoad: return "Wide"; + case CameraViewKind::QRoad: return "qRoad"; + } + return "Cam"; + }; + + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + ImGui::BeginChild("##cabana_video_header", ImVec2(0.0f, 32.0f), false, ImGuiWindowFlags_NoScrollbar); + app_push_bold_font(); + ImGui::TextUnformatted("Video"); + app_pop_bold_font(); + ImGui::SameLine(0.0f, 10.0f); + for (size_t i = 0; i < available_views.size(); ++i) { + const CameraViewSpec &spec = *available_views[i]; + if (i > 0) ImGui::SameLine(0.0f, 4.0f); + const float width = spec.view == CameraViewKind::Driver ? 66.0f : 58.0f; + if (draw_cabana_bottom_tab(("##video_" + std::to_string(i)).c_str(), + short_label(spec), + state->cabana.camera_view == spec.view, + width)) { + state->cabana.camera_view = spec.view; + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + + const CameraViewSpec &active_spec = camera_view_spec(state->cabana.camera_view); + CameraFeedView *feed = session->pane_camera_feeds[static_cast(active_spec.view)].get(); + if (feed != nullptr && state->has_tracker_time) { + feed->update(state->tracker_time); + } + if (feed == nullptr) { + ImGui::TextDisabled("Camera unavailable"); + return; + } + + static constexpr std::array kPlaybackRates = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1.0, 2.0, 3.0, 5.0}; + const float controls_h = 68.0f; + feed->drawSized(ImVec2(ImGui::GetContentRegionAvail().x, std::max(0.0f, height - controls_h)), + session->async_route_loading, + true); + + const double current = state->has_tracker_time ? state->tracker_time : session->route_data.x_min; + const double total = session->route_data.has_time_range ? session->route_data.x_max : current; + double slider_value = current; + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleColor(ImGuiCol_Border, cabana_border_color()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 5.0f)); + ImGui::BeginChild("##cabana_video_controls", ImVec2(0.0f, controls_h), true, ImGuiWindowFlags_NoScrollbar); + const float button_w = 24.0f; + if (ImGui::Button("|<", ImVec2(button_w, 0.0f))) { + step_tracker(state, -1.0); + } + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::Button(state->playback_playing ? "||" : ">", ImVec2(button_w, 0.0f))) { + state->playback_playing = !state->playback_playing; + } + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::Button(">|", ImVec2(button_w, 0.0f))) { + step_tracker(state, 1.0); + } + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("%s / %s", format_cabana_time(current).c_str(), format_cabana_time(total).c_str()); + ImGui::SameLine(0.0f, 12.0f); + ImGui::Checkbox("Loop", &state->playback_loop); + ImGui::SameLine(0.0f, 12.0f); + char rate_label[16]; + std::snprintf(rate_label, sizeof(rate_label), "%.2gx", state->playback_rate); + ImGui::SetNextItemWidth(72.0f); + if (ImGui::BeginCombo("##cabana_speed", rate_label)) { + for (double rate : kPlaybackRates) { + char option[16]; + std::snprintf(option, sizeof(option), "%.2gx", rate); + const bool selected = std::abs(state->playback_rate - rate) < 1.0e-9; + if (ImGui::Selectable(option, selected)) { + state->playback_rate = rate; + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + const ImVec2 bar_min = ImGui::GetCursorScreenPos(); + const float bar_w = ImGui::GetContentRegionAvail().x; + const float bar_h = 4.0f; + ImGui::Dummy(ImVec2(bar_w, bar_h)); + const ImRect bar_rect(bar_min, ImVec2(bar_min.x + bar_w, bar_min.y + bar_h)); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled(bar_rect.Min, bar_rect.Max, ImGui::GetColorU32(color_rgb(74, 77, 80))); + if (session->route_data.has_time_range && session->route_data.x_max > session->route_data.x_min) { + const double route_span = session->route_data.x_max - session->route_data.x_min; + for (const TimelineEntry &entry : session->route_data.timeline) { + const float x0 = static_cast((entry.start_time - session->route_data.x_min) / route_span); + const float x1 = static_cast((entry.end_time - session->route_data.x_min) / route_span); + const float left = std::clamp(x0, 0.0f, 1.0f); + const float right = std::clamp(x1, 0.0f, 1.0f); + if (right <= left) continue; + draw->AddRectFilled(ImVec2(bar_rect.Min.x + left * bar_rect.GetWidth(), bar_rect.Min.y), + ImVec2(bar_rect.Min.x + right * bar_rect.GetWidth(), bar_rect.Max.y), + timeline_entry_color(entry.type)); + } + const float tracker_x = static_cast((current - session->route_data.x_min) / route_span); + const float px = bar_rect.Min.x + std::clamp(tracker_x, 0.0f, 1.0f) * bar_rect.GetWidth(); + draw->AddLine(ImVec2(px, bar_rect.Min.y - 1.0f), ImVec2(px, bar_rect.Max.y + 1.0f), + ImGui::GetColorU32(color_rgb(232, 232, 232)), 1.5f); + } + ImGui::Dummy(ImVec2(0.0f, 3.0f)); + if (session->route_data.has_time_range) { + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::SliderScalar("##cabana_video_slider", + ImGuiDataType_Double, + &slider_value, + &session->route_data.x_min, + &session->route_data.x_max, + "")) { + state->tracker_time = slider_value; + state->has_tracker_time = true; + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); +} + +} // namespace + +void rebuild_cabana_messages(AppSession *session) { + std::vector messages; + const std::optional db = load_active_dbc(*session); + + messages.reserve(session->route_data.can_messages.size()); + for (const CanMessageData &message_data : session->route_data.can_messages) { + const dbc::Message *dbc_message = db.has_value() ? db->message(message_data.id.address) : nullptr; + CabanaMessageSummary message{ + .root_path = can_message_key(message_data.id.service, message_data.id.bus, message_data.id.address), + .service = can_service_name(message_data.id.service), + .name = dbc_message != nullptr ? dbc_message->name : format_can_address(message_data.id.address), + .node = dbc_message != nullptr ? dbc_message->transmitter : std::string(), + .bus = static_cast(message_data.id.bus), + .address = message_data.id.address, + .dbc_size = dbc_message != nullptr ? static_cast(dbc_message->size) : -1, + .has_address = true, + .sample_count = message_data.samples.size(), + }; + if (dbc_message != nullptr) { + const std::string base_path = "/" + message.service + "/" + std::to_string(message.bus) + "/" + dbc_message->name + "/"; + message.signals.reserve(dbc_message->signals.size()); + for (const dbc::Signal &dbc_signal : dbc_message->signals) { + const std::string path = base_path + dbc_signal.name; + if (session->series_by_path.find(path) == session->series_by_path.end()) { + continue; + } + message.signals.push_back(CabanaSignalSummary{ + .path = path, + .name = dbc_signal.name, + .unit = dbc_signal.unit, + .receiver_name = dbc_signal.receiver_name, + .comment = dbc_signal.comment, + .start_bit = dbc_signal.start_bit, + .msb = dbc_signal.msb, + .lsb = dbc_signal.lsb, + .size = dbc_signal.size, + .factor = dbc_signal.factor, + .offset = dbc_signal.offset, + .min = dbc_signal.min, + .max = dbc_signal.max, + .type = static_cast(dbc_signal.type), + .multiplex_value = dbc_signal.multiplex_value, + .value_description_count = static_cast(dbc_signal.value_descriptions.size()), + .is_signed = dbc_signal.is_signed, + .is_little_endian = dbc_signal.is_little_endian, + .has_bit_range = true, + }); + } + } + if (message_data.samples.size() > 1 + && message_data.samples.back().mono_time > message_data.samples.front().mono_time) { + message.frequency_hz = static_cast(message_data.samples.size() - 1) + / (message_data.samples.back().mono_time - message_data.samples.front().mono_time); + } + messages.push_back(std::move(message)); + } + + std::sort(messages.begin(), messages.end(), [](const CabanaMessageSummary &a, const CabanaMessageSummary &b) { + return std::make_tuple(a.service, a.bus, a.has_address ? 0 : 1, a.address, a.name) + < std::make_tuple(b.service, b.bus, b.has_address ? 0 : 1, b.address, b.name); + }); + session->cabana_messages = std::move(messages); +} + +void draw_cabana_mode(AppSession *session, const UiMetrics &ui, UiState *state) { + sync_cabana_selection(session, state); + + ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); + ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); + push_cabana_mode_style(); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings; + if (ImGui::Begin("##cabana_mode_host", nullptr, flags)) { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const CabanaMessageSummary *message = find_selected_message(*session, *state); + const float min_center_width = message != nullptr ? kMinCenterWidth : 140.0f; + const float content_h = std::max(200.0f, avail.y); + const float usable_w = std::max(kMinMessagesWidth + min_center_width + kMinRightWidth, + avail.x - 2.0f * kSplitterThickness); + const float min_left_frac = kMinMessagesWidth / usable_w; + const float min_center_frac = min_center_width / usable_w; + const float min_right_frac = kMinRightWidth / usable_w; + + state->cabana.layout_left_frac = std::clamp(state->cabana.layout_left_frac, + min_left_frac, + std::max(min_left_frac, 1.0f - min_center_frac - min_right_frac)); + state->cabana.layout_center_frac = std::clamp(state->cabana.layout_center_frac, + min_center_frac, + std::max(min_center_frac, 1.0f - state->cabana.layout_left_frac - min_right_frac)); + + float messages_width = std::floor(usable_w * state->cabana.layout_left_frac); + float center_width = std::floor(usable_w * state->cabana.layout_center_frac); + float right_width = usable_w - messages_width - center_width; + if (right_width < kMinRightWidth) { + right_width = kMinRightWidth; + center_width = std::max(min_center_width, usable_w - messages_width - right_width); + messages_width = std::max(kMinMessagesWidth, usable_w - center_width - right_width); + } + center_width = std::max(min_center_width, center_width); + state->cabana.layout_left_frac = messages_width / usable_w; + state->cabana.layout_center_frac = center_width / usable_w; + + const ImVec2 origin = ImGui::GetCursorPos(); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::BeginChild("##cabana_messages_panel", ImVec2(messages_width, content_h), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); + draw_messages_panel(session, state); + ImGui::EndChild(); + ImGui::PopStyleVar(); + + const float center_height = content_h; + ImGui::SetCursorPos(ImVec2(origin.x + messages_width, origin.y)); + draw_vertical_splitter("##cabana_left_splitter", content_h, kMinMessagesWidth, + std::max(kMinMessagesWidth, usable_w - min_center_width - right_width), + &messages_width); + messages_width = std::clamp(messages_width, kMinMessagesWidth, std::max(kMinMessagesWidth, usable_w - min_center_width - right_width)); + center_width = usable_w - messages_width - right_width; + state->cabana.layout_left_frac = messages_width / usable_w; + state->cabana.layout_center_frac = center_width / usable_w; + + ImGui::SetCursorPos(ImVec2(origin.x + messages_width + kSplitterThickness, origin.y)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::BeginChild("##cabana_detail_panel", ImVec2(center_width, center_height), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); + if (message == nullptr) { + draw_cabana_welcome_panel(); + } else { + draw_detail_panel(session, state, *message); + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + + ImGui::SetCursorPos(ImVec2(origin.x + messages_width + kSplitterThickness + center_width, origin.y)); + draw_right_splitter("##cabana_right_splitter", content_h, kMinRightWidth, + std::max(kMinRightWidth, usable_w - messages_width - min_center_width), + &right_width); + right_width = std::clamp(right_width, kMinRightWidth, std::max(kMinRightWidth, usable_w - messages_width - min_center_width)); + center_width = usable_w - messages_width - right_width; + state->cabana.layout_center_frac = center_width / usable_w; + + ImGui::SetCursorPos(ImVec2(origin.x + messages_width + center_width + 2.0f * kSplitterThickness, origin.y)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::BeginChild("##cabana_right_panel", ImVec2(right_width, center_height), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); + const float right_avail_y = ImGui::GetContentRegionAvail().y; + const float right_split_span = std::max(1.0f, right_avail_y - kSplitterThickness); + const float min_right_top_frac = kMinTopHeight / right_split_span; + const float min_right_bottom_frac = kMinBottomHeight / right_split_span; + state->cabana.layout_right_top_frac = std::clamp(state->cabana.layout_right_top_frac, + min_right_top_frac, + std::max(min_right_top_frac, 1.0f - min_right_bottom_frac)); + float right_top_height = std::clamp(std::floor(right_split_span * state->cabana.layout_right_top_frac), + kMinTopHeight, + std::max(kMinTopHeight, right_avail_y - kMinBottomHeight - kSplitterThickness)); + draw_video_panel(session, state, right_top_height); + if (draw_horizontal_splitter("##cabana_right_hsplit", + ImGui::GetContentRegionAvail().x, + kMinTopHeight, + std::max(kMinTopHeight, ImGui::GetContentRegionAvail().y - kMinBottomHeight), + &right_top_height)) { + state->cabana.layout_right_top_frac = std::clamp(right_top_height / right_split_span, + min_right_top_frac, + std::max(min_right_top_frac, 1.0f - min_right_bottom_frac)); + } + draw_chart_panel(session, state, message); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::SetCursorPos(ImVec2(origin.x, origin.y)); + ImGui::Dummy(avail); + } + ImGui::End(); + pop_cabana_mode_style(); + if (state->cabana.pending_apply_signal_edit) { + state->cabana.pending_apply_signal_edit = false; + apply_cabana_signal_edit(session, state); + } + if (state->cabana.pending_delete_signal) { + state->cabana.pending_delete_signal = false; + apply_cabana_signal_delete(session, state); + } +} diff --git a/tools/jotpluggler/app_cabana_widgets.cc b/tools/jotpluggler/app_cabana_widgets.cc new file mode 100644 index 00000000000000..164139c3561538 --- /dev/null +++ b/tools/jotpluggler/app_cabana_widgets.cc @@ -0,0 +1,1824 @@ +#include "tools/jotpluggler/app_internal.h" + +#include "implot.h" +#include "imgui_internal.h" + +#include +#include +#include +#include +#include +#include + +ImVec4 cabana_window_bg() { + return color_rgb(53, 53, 53); +} + +ImVec4 cabana_panel_bg() { + return color_rgb(60, 63, 65); +} + +ImVec4 cabana_panel_alt_bg() { + return color_rgb(46, 47, 49); +} + +ImVec4 cabana_border_color() { + return color_rgb(77, 77, 77); +} + +ImVec4 cabana_accent() { + return color_rgb(47, 101, 202); +} + +ImVec4 cabana_accent_hover() { + return color_rgb(64, 120, 224); +} + +ImVec4 cabana_accent_active() { + return color_rgb(74, 132, 236); +} + +ImVec4 cabana_muted_text() { + return color_rgb(153, 153, 153); +} + +void push_cabana_mode_style() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(5.0f, 3.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(4.0f, 2.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_GrabRounding, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 0.0f); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, cabana_window_bg()); + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_bg()); + ImGui::PushStyleColor(ImGuiCol_PopupBg, color_rgb(45, 45, 48)); + ImGui::PushStyleColor(ImGuiCol_Border, cabana_border_color()); + ImGui::PushStyleColor(ImGuiCol_Separator, cabana_border_color()); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(187, 187, 187)); + ImGui::PushStyleColor(ImGuiCol_TextDisabled, cabana_muted_text()); + ImGui::PushStyleColor(ImGuiCol_FrameBg, color_rgb(41, 41, 43)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, color_rgb(50, 53, 58)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, color_rgb(58, 61, 66)); + ImGui::PushStyleColor(ImGuiCol_Button, cabana_panel_bg()); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, color_rgb(74, 78, 82)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, cabana_accent()); + ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, color_rgb(45, 45, 48)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, color_rgb(92, 96, 101)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, color_rgb(112, 118, 126)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, color_rgb(132, 140, 150)); + ImGui::PushStyleColor(ImGuiCol_Header, cabana_accent()); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, cabana_accent_hover()); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, cabana_accent_active()); + ImGui::PushStyleColor(ImGuiCol_Tab, color_rgb(56, 58, 61)); + ImGui::PushStyleColor(ImGuiCol_TabHovered, color_rgb(70, 74, 79)); + ImGui::PushStyleColor(ImGuiCol_TabSelected, color_rgb(67, 70, 74)); + ImGui::PushStyleColor(ImGuiCol_TabSelectedOverline, cabana_accent()); + ImGui::PushStyleColor(ImGuiCol_TabDimmed, color_rgb(49, 51, 54)); + ImGui::PushStyleColor(ImGuiCol_TabDimmedSelected, color_rgb(61, 64, 68)); + ImGui::PushStyleColor(ImGuiCol_TabDimmedSelectedOverline, cabana_accent()); + ImGui::PushStyleColor(ImGuiCol_TableHeaderBg, color_rgb(47, 47, 50)); + ImGui::PushStyleColor(ImGuiCol_TableBorderStrong, cabana_border_color()); + ImGui::PushStyleColor(ImGuiCol_TableBorderLight, color_rgb(69, 69, 72)); + ImGui::PushStyleColor(ImGuiCol_TableRowBgAlt, color_rgb(65, 68, 71, 0.35f)); +} + +void pop_cabana_mode_style() { + ImGui::PopStyleColor(31); + ImGui::PopStyleVar(8); +} + +void draw_cabana_panel_title(const char *title, std::string_view subtitle) { + app_push_bold_font(); + ImGui::TextUnformatted(title); + app_pop_bold_font(); + if (!subtitle.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%.*s", static_cast(subtitle.size()), subtitle.data()); + } + ImGui::Spacing(); +} + +bool draw_cabana_bottom_tab(const char *id, const char *label, bool active, float width) { + ImGui::PushStyleColor(ImGuiCol_Button, active ? cabana_window_bg() : cabana_panel_alt_bg()); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, active ? cabana_window_bg() : color_rgb(67, 70, 73)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, cabana_window_bg()); + const bool clicked = ImGui::Button((std::string(label) + id).c_str(), ImVec2(width, 26.0f)); + ImGui::PopStyleColor(3); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddRect(rect.Min, rect.Max, ImGui::GetColorU32(active ? cabana_border_color() : color_rgb(92, 96, 101))); + if (active) { + draw->AddLine(ImVec2(rect.Min.x + 1.0f, rect.Max.y), ImVec2(rect.Max.x - 1.0f, rect.Max.y), + ImGui::GetColorU32(cabana_accent()), 2.0f); + } + return clicked; +} + +void draw_cabana_detail_tab_strip(UiState *state) { + const float strip_h = 30.0f; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 4.0f)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::BeginChild("##cabana_detail_bottom_tabs", ImVec2(0.0f, strip_h), false, ImGuiWindowFlags_NoScrollbar); + const ImVec2 pos = ImGui::GetWindowPos(); + const ImVec2 size = ImGui::GetWindowSize(); + const ImRect rect(pos, ImVec2(pos.x + size.x, pos.y + size.y)); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddLine(ImVec2(rect.Min.x, rect.Min.y + 1.0f), ImVec2(rect.Max.x, rect.Min.y + 1.0f), + ImGui::GetColorU32(cabana_border_color())); + ImGui::SetCursorPosX(8.0f); + if (draw_cabana_bottom_tab("##msg", "Msg", state->cabana.detail_tab == 0, 72.0f)) { + state->cabana.detail_tab = 0; + state->cabana.detail_top_auto_fit = true; + } + ImGui::SameLine(0.0f, 4.0f); + if (draw_cabana_bottom_tab("##logs", "Logs", state->cabana.detail_tab == 1, 76.0f)) { + state->cabana.detail_tab = 1; + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void draw_cabana_welcome_panel() { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float center_x = ImGui::GetCursorPosX() + avail.x * 0.5f; + ImGui::Dummy(ImVec2(0.0f, std::max(28.0f, avail.y * 0.18f))); + app_push_bold_font(); + const char *title = "CABANA"; + const float title_w = ImGui::CalcTextSize(title).x; + ImGui::SetCursorPosX(std::max(0.0f, center_x - title_w * 0.5f)); + ImGui::TextUnformatted(title); + app_pop_bold_font(); + ImGui::Spacing(); + const char *hint = "<-Select a message to view details"; + const float hint_w = ImGui::CalcTextSize(hint).x; + ImGui::SetCursorPosX(std::max(0.0f, center_x - hint_w * 0.5f)); + ImGui::TextDisabled("%s", hint); +} + +namespace { + +void draw_splitter_line(const ImRect &rect, bool hovered) { + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const ImU32 color = hovered ? IM_COL32(112, 128, 144, 255) : IM_COL32(194, 198, 204, 255); + if (rect.GetWidth() > rect.GetHeight()) { + const float y = (rect.Min.y + rect.Max.y) * 0.5f; + draw_list->AddLine(ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y), color, hovered ? 2.0f : 1.0f); + } else { + const float x = (rect.Min.x + rect.Max.x) * 0.5f; + draw_list->AddLine(ImVec2(x, rect.Min.y), ImVec2(x, rect.Max.y), color, hovered ? 2.0f : 1.0f); + } +} + +} // namespace + +void draw_vertical_splitter(const char *id, + float height, + float min_left, + float max_left, + float *left_width) { + const ImVec2 size(4.0f, height); + ImGui::InvisibleButton(id, size); + const bool hovered = ImGui::IsItemHovered() || ImGui::IsItemActive(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + if (ImGui::IsItemActive()) { + *left_width = std::clamp(*left_width + ImGui::GetIO().MouseDelta.x, min_left, max_left); + } + draw_splitter_line(ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), hovered); +} + +void draw_right_splitter(const char *id, + float height, + float min_right, + float max_right, + float *right_width) { + const ImVec2 size(4.0f, height); + ImGui::InvisibleButton(id, size); + const bool hovered = ImGui::IsItemHovered() || ImGui::IsItemActive(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + if (ImGui::IsItemActive()) { + *right_width = std::clamp(*right_width - ImGui::GetIO().MouseDelta.x, min_right, max_right); + } + draw_splitter_line(ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), hovered); +} + +bool draw_horizontal_splitter(const char *id, + float width, + float min_top, + float max_top, + float *top_height) { + const ImVec2 size(width, 4.0f); + ImGui::InvisibleButton(id, size); + const bool hovered = ImGui::IsItemHovered() || ImGui::IsItemActive(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + } + bool changed = false; + if (ImGui::IsItemActive()) { + *top_height = std::clamp(*top_height + ImGui::GetIO().MouseDelta.y, min_top, max_top); + changed = true; + } + draw_splitter_line(ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), hovered); + return changed; +} + +void draw_payload_bytes(std::string_view data, const std::string *prev_data) { + app_push_mono_font(); + for (size_t i = 0; i < data.size(); ++i) { + if (i > 0) ImGui::SameLine(0.0f, 6.0f); + const bool changed = prev_data != nullptr + && i < prev_data->size() + && static_cast((*prev_data)[i]) != static_cast(data[i]); + if (changed) ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(199, 74, 59)); + char hex[4]; + std::snprintf(hex, sizeof(hex), "%02X", static_cast(data[i])); + ImGui::TextUnformatted(hex); + if (changed) ImGui::PopStyleColor(); + } + app_pop_mono_font(); +} + +void draw_payload_preview_boxes(const char *id, std::string_view data, const std::string *prev_data, float max_width) { + constexpr float kByteW = 17.0f; + constexpr float kByteH = 16.0f; + constexpr float kGap = 2.0f; + const size_t capacity = std::max(1, static_cast((max_width + kGap) / (kByteW + kGap))); + const size_t visible = std::min(data.size(), capacity); + const bool truncated = visible < data.size(); + const float ellipsis_w = truncated ? 10.0f : 0.0f; + const float width = std::max(18.0f, visible * (kByteW + kGap) - (visible > 0 ? kGap : 0.0f) + ellipsis_w); + ImGui::InvisibleButton(id, ImVec2(width, kByteH)); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw = ImGui::GetWindowDrawList(); + app_push_mono_font(); + for (size_t i = 0; i < visible; ++i) { + const unsigned char after = static_cast(data[i]); + const bool has_prev = prev_data != nullptr && i < prev_data->size(); + const unsigned char before = has_prev ? static_cast((*prev_data)[i]) : after; + ImU32 fill = ImGui::GetColorU32(color_rgb(67, 70, 74)); + if (has_prev && after != before) { + fill = ImGui::GetColorU32(after > before ? color_rgb(72, 95, 140) : color_rgb(120, 72, 68)); + } + const float x0 = rect.Min.x + static_cast(i) * (kByteW + kGap); + const ImRect box(ImVec2(x0, rect.Min.y), ImVec2(x0 + kByteW, rect.Min.y + kByteH)); + draw->AddRectFilled(box.Min, box.Max, fill, 2.0f); + draw->AddRect(box.Min, box.Max, ImGui::GetColorU32(color_rgb(105, 110, 116)), 2.0f); + char hex[4]; + std::snprintf(hex, sizeof(hex), "%02X", after); + const ImVec2 text_size = ImGui::CalcTextSize(hex); + draw->AddText(ImGui::GetFont(), + ImGui::GetFontSize(), + ImVec2(box.Min.x + (box.GetWidth() - text_size.x) * 0.5f, + box.Min.y + (box.GetHeight() - text_size.y) * 0.5f - 1.0f), + ImGui::GetColorU32(color_rgb(228, 231, 236)), + hex); + } + if (truncated) { + draw->AddText(ImVec2(rect.Max.x - 9.0f, rect.Min.y - 1.0f), + ImGui::GetColorU32(color_rgb(154, 160, 168)), + "..."); + } + app_pop_mono_font(); +} + +void draw_signal_overlay_legend(const std::vector> &highlighted) { + if (highlighted.empty()) { + return; + } + app_push_bold_font(); + ImGui::TextUnformatted("Signals"); + app_pop_bold_font(); + for (size_t i = 0; i < highlighted.size(); ++i) { + if (i > 0) ImGui::SameLine(0.0f, 12.0f); + ImGui::ColorButton(("##cabana_signal_color_" + std::to_string(i)).c_str(), + ImGui::ColorConvertU32ToFloat4(highlighted[i].second), + ImGuiColorEditFlags_NoTooltip, + ImVec2(10.0f, 10.0f)); + ImGui::SameLine(0.0f, 6.0f); + ImGui::TextUnformatted(highlighted[i].first->name.c_str()); + ImGui::SameLine(0.0f, 6.0f); + ImGui::TextDisabled("[%d|%d]", highlighted[i].first->start_bit, highlighted[i].first->size); + } + ImGui::Spacing(); +} + +ImU32 mix_color(ImU32 a, ImU32 b, float t) { + const ImVec4 av = ImGui::ColorConvertU32ToFloat4(a); + const ImVec4 bv = ImGui::ColorConvertU32ToFloat4(b); + return ImGui::GetColorU32(ImVec4(av.x + (bv.x - av.x) * t, + av.y + (bv.y - av.y) * t, + av.z + (bv.z - av.z) * t, + av.w + (bv.w - av.w) * t)); +} + +void draw_empty_panel(const char *title, const char *message) { + draw_cabana_panel_title(title); + ImGui::TextDisabled("%s", message); +} + +void draw_cabana_toolbar_button(const char *label, bool enabled, const std::function &on_click) { + ImGui::BeginDisabled(!enabled); + if (ImGui::Button(label)) { + on_click(); + } + ImGui::EndDisabled(); +} + +void draw_cabana_warning_banner(const std::vector &warnings) { + if (warnings.empty()) { + return; + } + const float height = 28.0f + std::max(0.0f, (static_cast(warnings.size()) - 1.0f) * 16.0f); + ImGui::InvisibleButton("##cabana_warning_banner", ImVec2(ImGui::GetContentRegionAvail().x, height)); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(color_rgb(251, 245, 229)), 4.0f); + draw->AddRect(rect.Min, rect.Max, ImGui::GetColorU32(color_rgb(221, 191, 121)), 4.0f, 0, 1.0f); + draw->AddText(ImVec2(rect.Min.x + 10.0f, rect.Min.y + 6.0f), + ImGui::GetColorU32(color_rgb(164, 106, 28)), + "!"); + float y = rect.Min.y + 5.0f; + for (const std::string &warning : warnings) { + draw->AddText(ImVec2(rect.Min.x + 24.0f, y), + ImGui::GetColorU32(color_rgb(109, 82, 34)), + warning.c_str()); + y += 16.0f; + } +} + +void draw_signal_sparkline(const AppSession &session, + const UiState &state, + std::string_view signal_path, + bool selected, + ImVec2 size) { + const RouteSeries *series = app_find_route_series(session, std::string(signal_path)); + if (size.x <= 0.0f) { + size.x = std::max(96.0f, ImGui::GetContentRegionAvail().x); + } + if (size.y <= 0.0f) { + size.y = 24.0f; + } + if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { + ImGui::Dummy(size); + return; + } + + const std::string id = "##spark_" + std::string(signal_path); + ImGui::InvisibleButton(id.c_str(), size); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw = ImGui::GetWindowDrawList(); + const ImU32 bg = ImGui::GetColorU32(selected ? color_rgb(59, 74, 103) : color_rgb(52, 54, 57)); + const ImU32 border = ImGui::GetColorU32(selected ? color_rgb(110, 145, 214) : color_rgb(93, 98, 104)); + const ImU32 line = ImGui::GetColorU32(selected ? color_rgb(109, 163, 255) : color_rgb(162, 170, 182)); + const ImU32 tracker = ImGui::GetColorU32(color_rgb(214, 93, 64)); + draw->AddRectFilled(rect.Min, rect.Max, bg, 4.0f); + draw->AddRect(rect.Min, rect.Max, border, 4.0f); + + const double anchor = state.has_tracker_time + ? std::clamp(state.tracker_time, series->times.front(), series->times.back()) + : series->times.back(); + double x_max = anchor; + double x_min = std::max(series->times.front(), anchor - std::max(1, state.cabana.sparkline_range_sec)); + if (x_max <= x_min) { + x_min = series->times.front(); + x_max = series->times.back(); + } + if (x_max <= x_min) { + return; + } + + constexpr int kSamples = 40; + std::array sampled = {}; + std::array valid = {}; + bool found = false; + double y_min = 0.0; + double y_max = 0.0; + for (int i = 0; i < kSamples; ++i) { + const double t = x_min + (x_max - x_min) * static_cast(i) / static_cast(kSamples - 1); + const std::optional value = app_sample_xy_value_at_time(series->times, series->values, false, t); + if (!value.has_value() || !std::isfinite(*value)) continue; + sampled[static_cast(i)] = *value; + valid[static_cast(i)] = true; + if (!found) { + y_min = y_max = *value; + found = true; + } else { + y_min = std::min(y_min, *value); + y_max = std::max(y_max, *value); + } + } + if (!found) { + return; + } + if (y_max <= y_min) { + const double pad = std::max(0.1, std::abs(y_min) * 0.1); + y_min -= pad; + y_max += pad; + } else { + const double pad = (y_max - y_min) * 0.12; + y_min -= pad; + y_max += pad; + } + + const float left = rect.Min.x + 4.0f; + const float right = rect.Max.x - 4.0f; + const float top = rect.Min.y + 4.0f; + const float bottom = rect.Max.y - 4.0f; + std::array points = {}; + int point_count = 0; + for (int i = 0; i < kSamples; ++i) { + if (!valid[static_cast(i)]) { + if (point_count > 1) draw->AddPolyline(points.data(), point_count, line, 0, selected ? 2.0f : 1.5f); + point_count = 0; + continue; + } + const float x = left + (right - left) * static_cast(i) / static_cast(kSamples - 1); + const float frac = static_cast((sampled[static_cast(i)] - y_min) / (y_max - y_min)); + const float y = bottom - (bottom - top) * std::clamp(frac, 0.0f, 1.0f); + points[static_cast(point_count++)] = ImVec2(x, y); + } + if (point_count > 1) draw->AddPolyline(points.data(), point_count, line, 0, selected ? 2.0f : 1.5f); + + if (state.has_tracker_time && state.tracker_time >= x_min && state.tracker_time <= x_max) { + const float marker_x = left + (right - left) * static_cast((state.tracker_time - x_min) / (x_max - x_min)); + draw->AddLine(ImVec2(marker_x, top), ImVec2(marker_x, bottom), tracker, 1.5f); + } +} + +namespace { + +struct CabanaChartSeries { + const RouteSeries *series = nullptr; + const SeriesFormat *format = nullptr; + const EnumInfo *enum_info = nullptr; + std::string path; + std::string label; + std::array color = {109, 163, 255}; +}; + +const CabanaSignalSummary *find_message_signal(const CabanaMessageSummary &message, std::string_view path); + +std::string cabana_chart_value_label(const AppSession &session, std::string_view path, double tracker_time) { + const RouteSeries *series = app_find_route_series(session, std::string(path)); + if (series == nullptr || series->times.empty() || series->values.empty()) { + return "--"; + } + const auto value = app_sample_xy_value_at_time(series->times, series->values, false, tracker_time); + const auto format_it = session.route_data.series_formats.find(std::string(path)); + const auto enum_it = session.route_data.enum_info.find(std::string(path)); + if (!value.has_value() || format_it == session.route_data.series_formats.end()) { + return "--"; + } + return format_display_value(*value, + format_it->second, + enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second); +} + +std::optional> current_chart_range(const UiState &state) { + if (!state.has_shared_range || state.x_view_max <= state.x_view_min) { + return std::nullopt; + } + return std::pair(state.x_view_min, state.x_view_max); +} + +void apply_chart_range(UiState *state, std::optional> range) { + if (!range.has_value()) { + state->has_shared_range = true; + state->x_view_min = state->route_x_min; + state->x_view_max = std::max(state->route_x_min + MIN_HORIZONTAL_ZOOM_SECONDS, state->route_x_max); + } else { + state->has_shared_range = true; + state->x_view_min = std::clamp(range->first, state->route_x_min, state->route_x_max); + state->x_view_max = std::clamp(range->second, state->route_x_min, state->route_x_max); + if (state->x_view_max - state->x_view_min < MIN_HORIZONTAL_ZOOM_SECONDS) { + const double center = 0.5 * (state->x_view_min + state->x_view_max); + state->x_view_min = std::max(state->route_x_min, center - 0.5 * MIN_HORIZONTAL_ZOOM_SECONDS); + state->x_view_max = std::min(state->route_x_max, state->x_view_min + MIN_HORIZONTAL_ZOOM_SECONDS); + if (state->x_view_max - state->x_view_min < MIN_HORIZONTAL_ZOOM_SECONDS) { + state->x_view_min = std::max(state->route_x_min, state->route_x_max - MIN_HORIZONTAL_ZOOM_SECONDS); + state->x_view_max = state->route_x_max; + } + } + } +} + +void push_chart_zoom_history(UiState *state) { + const std::optional> range = current_chart_range(*state); + if (!state->cabana.chart_zoom_history.empty() && state->cabana.chart_zoom_history.back() == range) { + return; + } + state->cabana.chart_zoom_history.push_back(range); + if (state->cabana.chart_zoom_history.size() > 50) { + state->cabana.chart_zoom_history.erase(state->cabana.chart_zoom_history.begin()); + } + state->cabana.chart_zoom_redo.clear(); +} + +void update_chart_range(UiState *state, double center, double width, bool push_history = true) { + width = std::clamp(width, MIN_HORIZONTAL_ZOOM_SECONDS, std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->route_x_max - state->route_x_min)); + if (push_history) { + push_chart_zoom_history(state); + } + std::pair range(center - width * 0.5, center + width * 0.5); + if (range.first < state->route_x_min) { + range.second += state->route_x_min - range.first; + range.first = state->route_x_min; + } + if (range.second > state->route_x_max) { + range.first -= range.second - state->route_x_max; + range.second = state->route_x_max; + } + if (range.first < state->route_x_min) { + range.first = state->route_x_min; + } + apply_chart_range(state, range); +} + +void reset_chart_range(UiState *state) { + state->cabana.chart_zoom_history.clear(); + state->cabana.chart_zoom_redo.clear(); + apply_chart_range(state, std::nullopt); +} + +CabanaChartTabState *active_chart_tab(UiState *state) { + if (state->cabana.chart_tabs.empty()) { + return nullptr; + } + state->cabana.active_chart_tab = std::clamp(state->cabana.active_chart_tab, 0, + static_cast(state->cabana.chart_tabs.size()) - 1); + return &state->cabana.chart_tabs[static_cast(state->cabana.active_chart_tab)]; +} + +void ensure_chart_tabs(UiState *state) { + if (state->cabana.chart_tabs.empty()) { + state->cabana.chart_tabs.push_back(CabanaChartTabState{.id = state->cabana.next_chart_tab_id++}); + state->cabana.active_chart_tab = 0; + state->cabana.active_chart_index = 0; + } + state->cabana.active_chart_tab = std::clamp(state->cabana.active_chart_tab, 0, + static_cast(state->cabana.chart_tabs.size()) - 1); + CabanaChartTabState &tab = state->cabana.chart_tabs[static_cast(state->cabana.active_chart_tab)]; + state->cabana.active_chart_index = std::clamp(state->cabana.active_chart_index, 0, + std::max(0, static_cast(tab.charts.size()) - 1)); +} + +bool chart_has_signal(const CabanaChartState &chart, std::string_view path) { + return std::find(chart.signal_paths.begin(), chart.signal_paths.end(), path) != chart.signal_paths.end(); +} + +CabanaChartState *ensure_active_chart(UiState *state) { + ensure_chart_tabs(state); + CabanaChartTabState *tab = active_chart_tab(state); + if (tab == nullptr) { + return nullptr; + } + if (tab->charts.empty()) { + tab->charts.push_back(CabanaChartState{.id = state->cabana.next_chart_id++}); + state->cabana.active_chart_index = 0; + } + state->cabana.active_chart_index = std::clamp(state->cabana.active_chart_index, 0, + static_cast(tab->charts.size()) - 1); + return &tab->charts[static_cast(state->cabana.active_chart_index)]; +} + +void sync_chart_signal_aggregate(UiState *state) { + std::set ordered; + for (const CabanaChartTabState &tab : state->cabana.chart_tabs) { + for (const CabanaChartState &chart : tab.charts) { + for (const std::string &path : chart.signal_paths) { + ordered.insert(path); + } + } + } + state->cabana.chart_signal_paths.assign(ordered.begin(), ordered.end()); +} + +void reconcile_chart_tabs(UiState *state) { + ensure_chart_tabs(state); + std::set desired(state->cabana.chart_signal_paths.begin(), state->cabana.chart_signal_paths.end()); + std::set current; + for (CabanaChartTabState &tab : state->cabana.chart_tabs) { + for (CabanaChartState &chart : tab.charts) { + chart.signal_paths.erase(std::remove_if(chart.signal_paths.begin(), chart.signal_paths.end(), + [&](const std::string &path) { return !desired.count(path); }), + chart.signal_paths.end()); + chart.hidden.resize(chart.signal_paths.size(), false); + current.insert(chart.signal_paths.begin(), chart.signal_paths.end()); + } + tab.charts.erase(std::remove_if(tab.charts.begin(), tab.charts.end(), [](const CabanaChartState &chart) { + return chart.signal_paths.empty(); + }), + tab.charts.end()); + } + for (const std::string &path : desired) { + if (current.count(path)) { + continue; + } + CabanaChartState *chart = ensure_active_chart(state); + if (chart != nullptr && !chart_has_signal(*chart, path)) { + chart->signal_paths.push_back(path); + chart->hidden.resize(chart->signal_paths.size(), false); + } + } + ensure_chart_tabs(state); + sync_chart_signal_aggregate(state); +} + +double timeline_sec_from_mouse_x(double min_sec, double max_sec, float slider_x, float slider_w, float mouse_x) { + if (slider_w <= 0.0f || max_sec <= min_sec) { + return min_sec; + } + const float t = std::clamp((mouse_x - slider_x) / slider_w, 0.0f, 1.0f); + return min_sec + (max_sec - min_sec) * t; +} + +void draw_timeline_strip(ImDrawList *draw, + const ImVec2 &pos, + const ImVec2 &size, + const std::vector &timeline, + double min_sec, + double max_sec, + double current_sec, + std::optional> highlight_range, + double hover_sec) { + if (draw == nullptr || size.x <= 0.0f || size.y <= 0.0f || max_sec <= min_sec) { + return; + } + const auto sec_to_x = [&](double sec) { + const double t = (sec - min_sec) / std::max(0.001, max_sec - min_sec); + return pos.x + static_cast(t * size.x); + }; + draw->AddRectFilled(pos, ImVec2(pos.x + size.x, pos.y + size.y), + ImGui::GetColorU32(color_rgb(54, 57, 60))); + for (const TimelineEntry &entry : timeline) { + const float x0 = sec_to_x(std::clamp(entry.start_time, min_sec, max_sec)); + const float x1 = sec_to_x(std::clamp(entry.end_time, min_sec, max_sec)); + if (x1 <= x0) { + continue; + } + draw->AddRectFilled(ImVec2(x0, pos.y), ImVec2(x1, pos.y + size.y), timeline_entry_color(entry.type)); + } + if (highlight_range.has_value()) { + const float x0 = sec_to_x(std::clamp(highlight_range->first, min_sec, max_sec)); + const float x1 = sec_to_x(std::clamp(highlight_range->second, min_sec, max_sec)); + if (x1 > x0) { + draw->AddRectFilled(ImVec2(x0, pos.y), ImVec2(x1, pos.y + size.y), IM_COL32(255, 255, 255, 24)); + draw->AddRect(ImVec2(x0, pos.y), ImVec2(x1, pos.y + size.y), IM_COL32(230, 230, 230, 140)); + } + } + if (hover_sec >= min_sec && hover_sec <= max_sec) { + const float x = sec_to_x(hover_sec); + draw->AddLine(ImVec2(x, pos.y), ImVec2(x, pos.y + size.y), IM_COL32(255, 204, 68, 180), 1.5f); + } + const float cursor_x = sec_to_x(std::clamp(current_sec, min_sec, max_sec)); + draw->AddLine(ImVec2(cursor_x, pos.y - 1.0f), ImVec2(cursor_x, pos.y + size.y + 1.0f), IM_COL32(255, 255, 255, 210), 2.0f); +} + +} // namespace + +void draw_chart_panel(AppSession *session, UiState *state, const CabanaMessageSummary *message) { + auto build_chart_series = [&](std::string_view path, size_t color_index) -> std::optional { + const RouteSeries *series = app_find_route_series(*session, std::string(path)); + if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { + return std::nullopt; + } + static constexpr std::array, 8> kChartPalette = {{ + {109, 163, 255}, + {255, 122, 89}, + {92, 198, 131}, + {243, 191, 77}, + {176, 124, 255}, + {71, 191, 183}, + {234, 98, 98}, + {162, 170, 182}, + }}; + CabanaChartSeries out; + out.series = series; + out.path = std::string(path); + out.color = kChartPalette[color_index % kChartPalette.size()]; + const size_t slash = out.path.find_last_of('/'); + out.label = slash == std::string::npos ? out.path : out.path.substr(slash + 1); + auto format_it = session->route_data.series_formats.find(out.path); + if (format_it != session->route_data.series_formats.end()) { + out.format = &format_it->second; + } + auto enum_it = session->route_data.enum_info.find(out.path); + if (enum_it != session->route_data.enum_info.end()) { + out.enum_info = &enum_it->second; + } + return out; + }; + + auto visible_series_window = [&](const RouteSeries &series) { + 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()); + } + return std::pair(begin_index, end_index); + }; + + auto visible_y_bounds = [&](const RouteSeries &series, size_t begin_index, size_t end_index) { + double y_min = std::numeric_limits::max(); + double y_max = std::numeric_limits::lowest(); + for (size_t i = begin_index; i < end_index; ++i) { + y_min = std::min(y_min, series.values[i]); + y_max = std::max(y_max, series.values[i]); + } + if (y_min == std::numeric_limits::max() || y_max == std::numeric_limits::lowest()) { + y_min = 0.0; + y_max = 1.0; + } + if (y_max <= y_min) { + const double pad = std::max(std::abs(y_min) * 0.1, 1.0); + y_min -= pad; + y_max += pad; + } else { + const double pad = std::max((y_max - y_min) * 0.08, 1.0e-3); + y_min -= pad; + y_max += pad; + } + return std::pair(y_min, y_max); + }; + reconcile_chart_tabs(state); + const CabanaSignalSummary *selected_signal = (message != nullptr && !state->cabana.selected_signal_path.empty()) + ? find_message_signal(*message, state->cabana.selected_signal_path) + : nullptr; + CabanaChartTabState *tab = active_chart_tab(state); + const int active_series_type = (tab != nullptr && !tab->charts.empty()) + ? tab->charts[static_cast(std::clamp(state->cabana.active_chart_index, 0, static_cast(tab->charts.size()) - 1))].series_type + : 0; + const char *active_series_type_label = active_series_type == 1 ? "Step" : (active_series_type == 2 ? "Scatter" : "Line"); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + ImGui::BeginChild("##cabana_charts_header", ImVec2(0.0f, 34.0f), false, ImGuiWindowFlags_NoScrollbar); + app_push_bold_font(); + ImGui::Text("Charts: %zu", state->cabana.chart_signal_paths.size()); + app_pop_bold_font(); + ImGui::SameLine(0.0f, 8.0f); + if (ImGui::SmallButton("New Chart")) { + CabanaChartState chart{.id = state->cabana.next_chart_id++}; + if (selected_signal != nullptr) { + chart.signal_paths.push_back(selected_signal->path); + chart.hidden.push_back(false); + } + ensure_chart_tabs(state); + active_chart_tab(state)->charts.push_back(std::move(chart)); + state->cabana.active_chart_index = static_cast(active_chart_tab(state)->charts.size()) - 1; + sync_chart_signal_aggregate(state); + } + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::SmallButton("New Tab")) { + state->cabana.chart_tabs.push_back(CabanaChartTabState{.id = state->cabana.next_chart_tab_id++}); + state->cabana.active_chart_tab = static_cast(state->cabana.chart_tabs.size()) - 1; + state->cabana.active_chart_index = 0; + if (selected_signal != nullptr) { + CabanaChartState *chart = ensure_active_chart(state); + if (chart != nullptr && !chart_has_signal(*chart, selected_signal->path)) { + chart->signal_paths.push_back(selected_signal->path); + chart->hidden.resize(chart->signal_paths.size(), false); + } + } + sync_chart_signal_aggregate(state); + } + ImGui::SameLine(0.0f, 8.0f); + if (ImGui::BeginCombo("##chart_type_header", active_series_type_label)) { + if (ImGui::Selectable("Line", active_series_type == 0) && tab != nullptr && !tab->charts.empty()) { + tab->charts[static_cast(state->cabana.active_chart_index)].series_type = 0; + } + if (ImGui::Selectable("Step", active_series_type == 1) && tab != nullptr && !tab->charts.empty()) { + tab->charts[static_cast(state->cabana.active_chart_index)].series_type = 1; + } + if (ImGui::Selectable("Scatter", active_series_type == 2) && tab != nullptr && !tab->charts.empty()) { + tab->charts[static_cast(state->cabana.active_chart_index)].series_type = 2; + } + ImGui::EndCombo(); + } + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::BeginCombo("##chart_cols_header", ("Columns: " + std::to_string(state->cabana.chart_columns)).c_str())) { + for (int col = 1; col <= 3; ++col) { + if (ImGui::Selectable(std::to_string(col).c_str(), state->cabana.chart_columns == col)) { + state->cabana.chart_columns = col; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(0.0f, 6.0f); + ImGui::BeginDisabled(state->cabana.chart_zoom_history.empty()); + if (ImGui::SmallButton("Undo Zoom")) { + state->cabana.chart_zoom_redo.push_back(current_chart_range(*state)); + apply_chart_range(state, state->cabana.chart_zoom_history.back()); + state->cabana.chart_zoom_history.pop_back(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0.0f, 6.0f); + ImGui::BeginDisabled(state->cabana.chart_zoom_redo.empty()); + if (ImGui::SmallButton("Redo Zoom")) { + state->cabana.chart_zoom_history.push_back(current_chart_range(*state)); + apply_chart_range(state, state->cabana.chart_zoom_redo.back()); + state->cabana.chart_zoom_redo.pop_back(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0.0f, 6.0f); + if (ImGui::SmallButton("Reset")) { + reset_chart_range(state); + } + ImGui::SameLine(0.0f, 6.0f); + ImGui::BeginDisabled(selected_signal == nullptr); + if (ImGui::SmallButton("Add Signal")) { + CabanaChartState *chart = ensure_active_chart(state); + if (chart != nullptr && selected_signal != nullptr && !chart_has_signal(*chart, selected_signal->path)) { + chart->signal_paths.push_back(selected_signal->path); + chart->hidden.resize(chart->signal_paths.size(), false); + sync_chart_signal_aggregate(state); + } + } + ImGui::EndDisabled(); + ImGui::SameLine(0.0f, 6.0f); + if (ImGui::SmallButton("Remove All")) { + if (tab != nullptr) { + tab->charts.clear(); + } + sync_chart_signal_aggregate(state); + } + ImGui::SameLine(0.0f, 8.0f); + const auto range = current_chart_range(*state); + if (range.has_value()) { + ImGui::TextDisabled("%.2f - %.2f", range->first, range->second); + } else { + ImGui::TextDisabled("full route"); + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + + push_cabana_mode_style(); + if (state->cabana.chart_tabs.size() > 1 && ImGui::BeginTabBar("##cabana_chart_tabs", ImGuiTabBarFlags_FittingPolicyResizeDown | ImGuiTabBarFlags_Reorderable)) { + int remove_tab = -1; + int duplicate_tab = -1; + int close_other_tab = -1; + for (int i = 0; i < static_cast(state->cabana.chart_tabs.size()); ++i) { + bool open = true; + const std::string label = "Tab " + std::to_string(i + 1) + " (" + std::to_string(state->cabana.chart_tabs[static_cast(i)].charts.size()) + ")"; + const ImGuiTabItemFlags flags = state->cabana.active_chart_tab == i ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), &open, flags)) { + state->cabana.active_chart_tab = i; + ImGui::EndTabItem(); + } + if (ImGui::BeginPopupContextItem(("##chart_tab_ctx" + std::to_string(state->cabana.chart_tabs[static_cast(i)].id)).c_str())) { + if (ImGui::MenuItem("Duplicate Tab")) duplicate_tab = i; + if (ImGui::MenuItem("Close Other Tabs", nullptr, false, state->cabana.chart_tabs.size() > 1)) close_other_tab = i; + if (ImGui::MenuItem("Close Tab", nullptr, false, state->cabana.chart_tabs.size() > 1)) remove_tab = i; + ImGui::EndPopup(); + } + if (!open && state->cabana.chart_tabs.size() > 1) remove_tab = i; + } + if (duplicate_tab >= 0) { + CabanaChartTabState dup = state->cabana.chart_tabs[static_cast(duplicate_tab)]; + dup.id = state->cabana.next_chart_tab_id++; + for (CabanaChartState &chart : dup.charts) { + chart.id = state->cabana.next_chart_id++; + } + state->cabana.chart_tabs.insert(state->cabana.chart_tabs.begin() + duplicate_tab + 1, std::move(dup)); + state->cabana.active_chart_tab = duplicate_tab + 1; + } + if (close_other_tab >= 0) { + CabanaChartTabState keep = std::move(state->cabana.chart_tabs[static_cast(close_other_tab)]); + state->cabana.chart_tabs.assign(1, std::move(keep)); + state->cabana.active_chart_tab = 0; + } + if (remove_tab >= 0 && state->cabana.chart_tabs.size() > 1) { + state->cabana.chart_tabs.erase(state->cabana.chart_tabs.begin() + remove_tab); + state->cabana.active_chart_tab = std::clamp(state->cabana.active_chart_tab, 0, + static_cast(state->cabana.chart_tabs.size()) - 1); + } + ImGui::EndTabBar(); + } + pop_cabana_mode_style(); + + tab = active_chart_tab(state); + const auto display_range = current_chart_range(*state).value_or(std::pair(state->route_x_min, state->route_x_max)); + double x_min = display_range.first; + double x_max = display_range.second; + + const ImVec2 timeline_pos = ImGui::GetCursorScreenPos(); + const ImVec2 timeline_size(ImGui::GetContentRegionAvail().x, 14.0f); + ImGui::InvisibleButton("##cabana_chart_timeline", timeline_size); + const bool timeline_hovered = ImGui::IsItemHovered(); + if (timeline_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + state->cabana.chart_timeline_zoom_drag_active = true; + state->cabana.chart_timeline_zoom_start_x = std::clamp(ImGui::GetIO().MousePos.x, timeline_pos.x, timeline_pos.x + timeline_size.x); + state->cabana.chart_timeline_zoom_min_x = timeline_pos.x; + state->cabana.chart_timeline_zoom_max_x = timeline_pos.x + timeline_size.x; + state->cabana.chart_timeline_zoom_range_min = x_min; + state->cabana.chart_timeline_zoom_range_max = x_max; + } + const double timeline_hover_sec = (timeline_hovered || state->cabana.chart_timeline_zoom_drag_active) + ? timeline_sec_from_mouse_x(x_min, x_max, timeline_pos.x, timeline_size.x, ImGui::GetIO().MousePos.x) + : -1.0; + draw_timeline_strip(ImGui::GetWindowDrawList(), + timeline_pos, + timeline_size, + session->route_data.timeline, + x_min, + x_max, + state->tracker_time, + state->cabana.chart_timeline_zoom_drag_active + ? std::optional>(std::pair( + std::min(timeline_sec_from_mouse_x(state->cabana.chart_timeline_zoom_range_min, state->cabana.chart_timeline_zoom_range_max, + timeline_pos.x, timeline_size.x, state->cabana.chart_timeline_zoom_start_x), + timeline_hover_sec), + std::max(timeline_sec_from_mouse_x(state->cabana.chart_timeline_zoom_range_min, state->cabana.chart_timeline_zoom_range_max, + timeline_pos.x, timeline_size.x, state->cabana.chart_timeline_zoom_start_x), + timeline_hover_sec))) + : std::nullopt, + timeline_hover_sec >= 0 ? timeline_hover_sec : state->cabana.chart_hover_sec); + if (state->cabana.chart_timeline_zoom_drag_active && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + const float current_x = std::clamp(ImGui::GetIO().MousePos.x, state->cabana.chart_timeline_zoom_min_x, state->cabana.chart_timeline_zoom_max_x); + const float drag_px = std::abs(current_x - state->cabana.chart_timeline_zoom_start_x); + if (drag_px > 6.0f) { + const double zoom_min = timeline_sec_from_mouse_x(state->cabana.chart_timeline_zoom_range_min, + state->cabana.chart_timeline_zoom_range_max, + state->cabana.chart_timeline_zoom_min_x, + state->cabana.chart_timeline_zoom_max_x - state->cabana.chart_timeline_zoom_min_x, + std::min(current_x, state->cabana.chart_timeline_zoom_start_x)); + const double zoom_max = timeline_sec_from_mouse_x(state->cabana.chart_timeline_zoom_range_min, + state->cabana.chart_timeline_zoom_range_max, + state->cabana.chart_timeline_zoom_min_x, + state->cabana.chart_timeline_zoom_max_x - state->cabana.chart_timeline_zoom_min_x, + std::max(current_x, state->cabana.chart_timeline_zoom_start_x)); + if (zoom_max - zoom_min > MIN_HORIZONTAL_ZOOM_SECONDS) { + update_chart_range(state, 0.5 * (zoom_min + zoom_max), zoom_max - zoom_min); + } + } else if (timeline_hover_sec >= 0.0) { + state->tracker_time = std::clamp(timeline_hover_sec, state->route_x_min, state->route_x_max); + state->has_tracker_time = true; + } + state->cabana.chart_timeline_zoom_drag_active = false; + } + ImGui::Dummy(ImVec2(0.0f, 4.0f)); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_window_bg()); + ImGui::BeginChild("##cabana_chart_plot", ImVec2(0.0f, 0.0f), false, ImGuiWindowFlags_AlwaysVerticalScrollbar); + if (tab == nullptr || tab->charts.empty()) { + ImGui::TextDisabled("No charts. Use 'New Chart' or 'Add Signal'."); + ImGui::EndChild(); + ImGui::PopStyleColor(); + sync_chart_signal_aggregate(state); + return; + } + + int remove_chart_idx = -1; + int split_chart_idx = -1; + int drag_src_idx = -1; + int drag_dst_idx = -1; + bool drag_insert_after = false; + double hover_sec_this_frame = -1.0; + const int chart_count = static_cast(tab->charts.size()); + const int eff_columns = std::min(std::clamp(state->cabana.chart_columns, 1, 3), std::max(1, chart_count)); + const int rows = (chart_count + eff_columns - 1) / eff_columns; + const float gap = 4.0f; + const float cell_w = std::max(220.0f, (ImGui::GetContentRegionAvail().x - gap * (eff_columns - 1)) / eff_columns); + const float cell_h = std::max(140.0f, (ImGui::GetContentRegionAvail().y - gap * std::max(0, rows - 1)) / std::max(1, rows)); + + for (int ci = 0; ci < chart_count; ++ci) { + CabanaChartState &chart = tab->charts[static_cast(ci)]; + if ((ci % eff_columns) != 0) { + ImGui::SameLine(0.0f, gap); + } + std::vector series_entries; + series_entries.reserve(chart.signal_paths.size()); + for (size_t i = 0; i < chart.signal_paths.size(); ++i) { + if (auto entry = build_chart_series(chart.signal_paths[i], i); entry.has_value()) { + series_entries.push_back(std::move(*entry)); + } + } + if (static_cast(chart.hidden.size()) < static_cast(chart.signal_paths.size())) { + chart.hidden.resize(chart.signal_paths.size(), false); + } + + ImGui::PushID(chart.id); + ImGui::BeginChild("##cabana_chart_cell", ImVec2(cell_w, cell_h), true); + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup)) { + state->cabana.active_chart_index = ci; + } + ImGui::BeginGroup(); + const std::string drag_id = "##chart_drag_" + std::to_string(chart.id); + ImGui::SmallButton(drag_id.c_str()); + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("CABANA_CHART", &ci, sizeof(int)); + ImGui::TextUnformatted("Drag chart"); + ImGui::TextDisabled("Drop onto a chart to merge"); + ImGui::TextDisabled("Hold Shift to reorder"); + ImGui::EndDragDropSource(); + } + ImGui::SameLine(0.0f, 6.0f); + for (size_t si = 0; si < series_entries.size(); ++si) { + if (si > 0) ImGui::SameLine(); + const bool hidden = si < chart.hidden.size() && chart.hidden[si]; + if (!hidden) { + ImGui::TextColored(color_rgb(series_entries[si].color), "%s", series_entries[si].label.c_str()); + } else { + ImGui::TextDisabled("[%s]", series_entries[si].label.c_str()); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && si < chart.hidden.size()) { + chart.hidden[si] = !chart.hidden[si]; + } + ImGui::SameLine(0.0f, 4.0f); + ImGui::TextDisabled("%s", cabana_chart_value_label(*session, series_entries[si].path, state->tracker_time).c_str()); + } + ImGui::SameLine(std::max(ImGui::GetCursorPosX() + 6.0f, ImGui::GetWindowContentRegionMax().x - 90.0f)); + if (ImGui::SmallButton("Type")) { + ImGui::OpenPopup("##chart_type_popup"); + } + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::SmallButton("x")) { + remove_chart_idx = ci; + } + if (ImGui::BeginPopup("##chart_type_popup")) { + if (ImGui::MenuItem("Line", nullptr, chart.series_type == 0)) chart.series_type = 0; + if (ImGui::MenuItem("Step", nullptr, chart.series_type == 1)) chart.series_type = 1; + if (ImGui::MenuItem("Scatter", nullptr, chart.series_type == 2)) chart.series_type = 2; + ImGui::Separator(); + if (series_entries.size() > 1 && ImGui::MenuItem("Split Chart")) split_chart_idx = ci; + if (ImGui::MenuItem("Close Chart")) remove_chart_idx = ci; + ImGui::Separator(); + if (ImGui::MenuItem("Undo Zoom", nullptr, false, !state->cabana.chart_zoom_history.empty())) { + state->cabana.chart_zoom_redo.push_back(current_chart_range(*state)); + apply_chart_range(state, state->cabana.chart_zoom_history.back()); + state->cabana.chart_zoom_history.pop_back(); + } + if (ImGui::MenuItem("Redo Zoom", nullptr, false, !state->cabana.chart_zoom_redo.empty())) { + state->cabana.chart_zoom_history.push_back(current_chart_range(*state)); + apply_chart_range(state, state->cabana.chart_zoom_redo.back()); + state->cabana.chart_zoom_redo.pop_back(); + } + if (ImGui::MenuItem("Reset Zoom")) { + reset_chart_range(state); + } + ImGui::EndPopup(); + } + ImGui::EndGroup(); + + const ImVec2 plot_size(ImGui::GetContentRegionAvail().x, std::max(96.0f, ImGui::GetContentRegionAvail().y - 2.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, color_rgb(52, 54, 57)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, color_rgb(60, 63, 66)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, color_rgb(67, 70, 74)); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(8.0f, 6.0f)); + ImPlot::PushStyleVar(ImPlotStyleVar_LabelPadding, ImVec2(5.0f, 2.0f)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, color_rgb(52, 54, 57)); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, color_rgb(95, 100, 106)); + ImPlot::PushStyleColor(ImPlotCol_LegendBg, color_rgb(46, 47, 49, 0.94f)); + ImPlot::PushStyleColor(ImPlotCol_LegendBorder, color_rgb(95, 100, 106)); + ImPlot::PushStyleColor(ImPlotCol_LegendText, color_rgb(220, 224, 229)); + ImPlot::PushStyleColor(ImPlotCol_TitleText, color_rgb(220, 224, 229)); + ImPlot::PushStyleColor(ImPlotCol_InlayText, color_rgb(214, 219, 225)); + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, color_rgb(86, 90, 96)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, color_rgb(182, 188, 196)); + ImPlot::PushStyleColor(ImPlotCol_AxisBg, color_rgb(47, 49, 52)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgHovered, color_rgb(52, 54, 57, 0.96f)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgActive, color_rgb(58, 61, 65, 0.98f)); + ImPlot::PushStyleColor(ImPlotCol_Selection, color_rgb(117, 161, 242, 0.22f)); + ImPlot::PushStyleColor(ImPlotCol_Crosshairs, color_rgb(214, 219, 225, 0.70f)); + + if (ImPlot::BeginPlot("##cabana_signal_plot", plot_size, ImPlotFlags_NoMenus | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMouseText | ImPlotFlags_NoLegend)) { + ImPlotAxisFlags x_flags = rows > 1 && ci < chart_count - eff_columns ? ImPlotAxisFlags_NoTickLabels : ImPlotAxisFlags_None; + ImPlot::SetupAxes(ci >= chart_count - eff_columns ? "Time (s)" : nullptr, nullptr, + x_flags | ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight, + ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight); + ImPlot::SetupAxisLinks(ImAxis_X1, &state->x_view_min, &state->x_view_max); + if (state->route_x_max > state->route_x_min) { + ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, state->route_x_min, state->route_x_max); + } + + double local_min = std::numeric_limits::max(); + double local_max = std::numeric_limits::lowest(); + for (size_t si = 0; si < series_entries.size(); ++si) { + if (si < chart.hidden.size() && chart.hidden[si]) continue; + const auto [begin_index, end_index] = visible_series_window(*series_entries[si].series); + const auto [y_min, y_max] = visible_y_bounds(*series_entries[si].series, begin_index, end_index); + local_min = std::min(local_min, y_min); + local_max = std::max(local_max, y_max); + } + if (local_min == std::numeric_limits::max() || local_max == std::numeric_limits::lowest()) { + local_min = -1.0; + local_max = 1.0; + } + ImPlot::SetupAxisLimits(ImAxis_Y1, local_min, local_max, ImPlotCond_Always); + + const bool plot_hovered = ImPlot::IsPlotHovered(); + const double hover_sec = plot_hovered ? ImPlot::GetPlotMousePos().x : state->cabana.chart_hover_sec; + if (plot_hovered) hover_sec_this_frame = hover_sec; + + for (size_t si = 0; si < series_entries.size(); ++si) { + const CabanaChartSeries &entry = series_entries[si]; + if (si < chart.hidden.size() && chart.hidden[si]) continue; + const auto [begin_index, end_index] = visible_series_window(*entry.series); + if (end_index <= begin_index + 1) continue; + ImPlotSpec spec; + spec.LineColor = color_rgb(entry.color); + spec.LineWeight = 2.0f; + const int count = static_cast(end_index - begin_index); + const double *xs = entry.series->times.data() + begin_index; + const double *ys = entry.series->values.data() + begin_index; + const std::string legend_label = entry.label + "##" + entry.path; + if (chart.series_type == 1) { + spec.Flags = ImPlotStairsFlags_PreStep; + ImPlot::PlotStairs(legend_label.c_str(), xs, ys, count, spec); + } else if (chart.series_type == 2) { + spec.Flags = ImPlotScatterFlags_None; + ImPlot::PlotScatter(legend_label.c_str(), xs, ys, count, spec); + } else { + spec.Flags = ImPlotLineFlags_SkipNaN; + ImPlot::PlotLine(legend_label.c_str(), xs, ys, count, spec); + } + + if (hover_sec >= state->route_x_min && hover_sec <= state->route_x_max) { + auto it = std::upper_bound(entry.series->times.begin(), entry.series->times.end(), hover_sec); + const int idx = (it == entry.series->times.begin()) ? 0 : static_cast(it - entry.series->times.begin()) - 1; + if (idx >= 0 && idx < static_cast(entry.series->times.size())) { + const ImVec2 pos = ImPlot::PlotToPixels(entry.series->times[static_cast(idx)], entry.series->values[static_cast(idx)]); + ImDrawList *draw = ImPlot::GetPlotDrawList(); + draw->AddCircleFilled(pos, 4.5f, ImGui::GetColorU32(color_rgb(entry.color))); + draw->AddCircle(pos, 4.5f, IM_COL32(255, 255, 255, 180), 0, 1.2f); + } + } + } + + if (state->has_tracker_time) { + const double clamped = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); + ImPlotSpec cursor_spec; + cursor_spec.LineColor = color_rgb(214, 219, 225, 0.7f); + cursor_spec.LineWeight = 1.0f; + cursor_spec.Flags = ImPlotItemFlags_NoLegend; + ImPlot::PlotInfLines("##tracker_cursor", &clamped, 1, cursor_spec); + } + + if (plot_hovered) { + if (ImGui::GetIO().MouseWheel != 0.0f) { + const double center = std::clamp(hover_sec, state->route_x_min, state->route_x_max); + const double width = std::clamp((state->x_view_max - state->x_view_min) * (ImGui::GetIO().MouseWheel > 0.0f ? 0.8 : 1.25), + MIN_HORIZONTAL_ZOOM_SECONDS, + std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->route_x_max - state->route_x_min)); + update_chart_range(state, center, width); + } + if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { + const ImVec2 delta_px = ImGui::GetIO().MouseDelta; + if (std::abs(delta_px.x) > 0.1f) { + const ImPlotRect limits = ImPlot::GetPlotLimits(); + const double pps = ImPlot::GetPlotSize().x / (limits.X.Max - limits.X.Min); + update_chart_range(state, 0.5 * (state->x_view_min + state->x_view_max) - delta_px.x / pps, + state->x_view_max - state->x_view_min, false); + } + } + if (ImGui::IsMouseDragging(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift) { + if (!state->cabana.chart_scrub_was_playing && state->playback_playing) { + state->cabana.chart_scrub_was_playing = true; + state->playback_playing = false; + } + state->tracker_time = std::clamp(hover_sec, state->route_x_min, state->route_x_max); + state->has_tracker_time = true; + } + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) { + state->cabana.chart_zoom_drag_active = true; + state->cabana.chart_zoom_drag_chart_id = chart.id; + const ImVec2 plot_pos = ImPlot::GetPlotPos(); + const ImVec2 plot_sz = ImPlot::GetPlotSize(); + state->cabana.chart_zoom_drag_plot_min_x = plot_pos.x; + state->cabana.chart_zoom_drag_plot_min_y = plot_pos.y; + state->cabana.chart_zoom_drag_plot_max_x = plot_pos.x + plot_sz.x; + state->cabana.chart_zoom_drag_plot_max_y = plot_pos.y + plot_sz.y; + state->cabana.chart_zoom_drag_start_x = std::clamp(ImGui::GetIO().MousePos.x, plot_pos.x, plot_pos.x + plot_sz.x); + } + } + if (state->cabana.chart_zoom_drag_active && state->cabana.chart_zoom_drag_chart_id == chart.id) { + const float cur_x = std::clamp(ImGui::GetIO().MousePos.x, + state->cabana.chart_zoom_drag_plot_min_x, + state->cabana.chart_zoom_drag_plot_max_x); + const float drag_px = std::abs(cur_x - state->cabana.chart_zoom_drag_start_x); + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift && drag_px > 6.0f) { + ImDrawList *overlay = ImPlot::GetPlotDrawList(); + const float sel_min_x = std::min(cur_x, state->cabana.chart_zoom_drag_start_x); + const float sel_max_x = std::max(cur_x, state->cabana.chart_zoom_drag_start_x); + overlay->AddRectFilled(ImVec2(sel_min_x, state->cabana.chart_zoom_drag_plot_min_y), + ImVec2(sel_max_x, state->cabana.chart_zoom_drag_plot_max_y), + IM_COL32(180, 205, 230, 40)); + overlay->AddRect(ImVec2(sel_min_x, state->cabana.chart_zoom_drag_plot_min_y), + ImVec2(sel_max_x, state->cabana.chart_zoom_drag_plot_max_y), + IM_COL32(180, 205, 230, 180), 0.0f, 0, 1.0f); + } + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + if (!ImGui::GetIO().KeyShift && drag_px > 6.0f) { + const double min_x = std::clamp(ImPlot::PixelsToPlot(ImVec2(std::min(cur_x, state->cabana.chart_zoom_drag_start_x), + state->cabana.chart_zoom_drag_plot_min_y)).x, + state->route_x_min, state->route_x_max); + const double max_x = std::clamp(ImPlot::PixelsToPlot(ImVec2(std::max(cur_x, state->cabana.chart_zoom_drag_start_x), + state->cabana.chart_zoom_drag_plot_min_y)).x, + state->route_x_min, state->route_x_max); + if (max_x - min_x > MIN_HORIZONTAL_ZOOM_SECONDS) { + update_chart_range(state, 0.5 * (min_x + max_x), max_x - min_x); + } + } else if (!ImGui::GetIO().KeyShift && plot_hovered) { + state->tracker_time = std::clamp(hover_sec, state->route_x_min, state->route_x_max); + state->has_tracker_time = true; + } + state->cabana.chart_zoom_drag_active = false; + state->cabana.chart_zoom_drag_chart_id = -1; + } + } + + if (ImGui::BeginPopupContextWindow("##chart_ctx")) { + if (ImGui::MenuItem("Line", nullptr, chart.series_type == 0)) chart.series_type = 0; + if (ImGui::MenuItem("Step", nullptr, chart.series_type == 1)) chart.series_type = 1; + if (ImGui::MenuItem("Scatter", nullptr, chart.series_type == 2)) chart.series_type = 2; + ImGui::Separator(); + if (series_entries.size() > 1 && ImGui::MenuItem("Split Chart")) split_chart_idx = ci; + if (ImGui::MenuItem("Close Chart")) remove_chart_idx = ci; + ImGui::EndPopup(); + } + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(12); + ImPlot::PopStyleVar(2); + ImGui::PopStyleColor(3); + ImGui::EndChild(); + + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("CABANA_CHART")) { + drag_src_idx = *static_cast(payload->Data); + drag_dst_idx = ci; + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + drag_insert_after = ImGui::GetMousePos().y >= (rect.Min.y + rect.Max.y) * 0.5f; + } + ImGui::EndDragDropTarget(); + } + ImGui::PopID(); + } + + if (state->cabana.chart_scrub_was_playing && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + state->playback_playing = true; + state->cabana.chart_scrub_was_playing = false; + } + state->cabana.chart_hover_sec = hover_sec_this_frame >= 0.0 ? hover_sec_this_frame : state->cabana.chart_hover_sec; + + if (split_chart_idx >= 0 && split_chart_idx < static_cast(tab->charts.size())) { + CabanaChartState &src = tab->charts[static_cast(split_chart_idx)]; + if (src.signal_paths.size() > 1) { + int pos = split_chart_idx + 1; + for (size_t si = 1; si < src.signal_paths.size(); ++si) { + CabanaChartState chart_copy{.id = state->cabana.next_chart_id++}; + chart_copy.series_type = src.series_type; + chart_copy.signal_paths = {src.signal_paths[si]}; + chart_copy.hidden = {false}; + tab->charts.insert(tab->charts.begin() + pos, std::move(chart_copy)); + ++pos; + } + src.signal_paths.resize(1); + src.hidden.resize(1, false); + } + } + if (remove_chart_idx >= 0 && remove_chart_idx < static_cast(tab->charts.size())) { + tab->charts.erase(tab->charts.begin() + remove_chart_idx); + state->cabana.active_chart_index = std::clamp(state->cabana.active_chart_index, 0, + std::max(0, static_cast(tab->charts.size()) - 1)); + } + if (drag_src_idx >= 0 && drag_dst_idx >= 0 && drag_src_idx != drag_dst_idx + && drag_src_idx < static_cast(tab->charts.size()) && drag_dst_idx < static_cast(tab->charts.size())) { + if (ImGui::GetIO().KeyShift) { + CabanaChartState moved = std::move(tab->charts[static_cast(drag_src_idx)]); + tab->charts.erase(tab->charts.begin() + drag_src_idx); + int dst = drag_insert_after ? drag_dst_idx + 1 : drag_dst_idx; + if (drag_src_idx < dst) dst--; + dst = std::clamp(dst, 0, static_cast(tab->charts.size())); + tab->charts.insert(tab->charts.begin() + dst, std::move(moved)); + state->cabana.active_chart_index = dst; + } else { + CabanaChartState &src = tab->charts[static_cast(drag_src_idx)]; + CabanaChartState &dst = tab->charts[static_cast(drag_dst_idx)]; + for (const std::string &path : src.signal_paths) { + if (!chart_has_signal(dst, path)) { + dst.signal_paths.push_back(path); + dst.hidden.push_back(false); + } + } + tab->charts.erase(tab->charts.begin() + drag_src_idx); + state->cabana.active_chart_index = std::clamp(drag_dst_idx, 0, std::max(0, static_cast(tab->charts.size()) - 1)); + } + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); + sync_chart_signal_aggregate(state); +} + +namespace { + +constexpr std::array, 8> kCabanaSignalPalette = {{ + {102, 86, 169}, + {69, 137, 255}, + {55, 171, 112}, + {232, 171, 44}, + {198, 89, 71}, + {92, 155, 181}, + {134, 172, 79}, + {150, 112, 63}, +}}; + +bool signal_matches_filter(const CabanaSignalSummary &signal, std::string_view filter) { + if (filter.empty()) { + return true; + } + const std::string needle = lowercase(filter); + return lowercase(signal.name).find(needle) != std::string::npos + || lowercase(signal.unit).find(needle) != std::string::npos + || lowercase(signal.receiver_name).find(needle) != std::string::npos; +} + +bool cabana_signal_charted(const UiState &state, std::string_view path) { + return std::find(state.cabana.chart_signal_paths.begin(), state.cabana.chart_signal_paths.end(), path) + != state.cabana.chart_signal_paths.end(); +} + +const CabanaSignalSummary *find_message_signal(const CabanaMessageSummary &message, std::string_view path) { + auto it = std::find_if(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) { + return signal.path == path; + }); + return it == message.signals.end() ? nullptr : &*it; +} + +void toggle_cabana_signal_chart(UiState *state, std::string_view path, bool enabled, bool new_chart_on_enable = false) { + if (enabled && new_chart_on_enable) { + ensure_chart_tabs(state); + CabanaChartTabState *tab = active_chart_tab(state); + if (tab != nullptr) { + CabanaChartState chart{.id = state->cabana.next_chart_id++}; + chart.signal_paths.push_back(std::string(path)); + chart.hidden.push_back(false); + tab->charts.push_back(std::move(chart)); + state->cabana.active_chart_index = static_cast(tab->charts.size()) - 1; + sync_chart_signal_aggregate(state); + } + return; + } + auto &paths = state->cabana.chart_signal_paths; + auto it = std::find(paths.begin(), paths.end(), path); + if (enabled) { + if (it == paths.end()) { + paths.emplace_back(path); + } + } else if (it != paths.end()) { + paths.erase(it); + } +} + +void load_inline_signal_editor(UiState *state, + const CabanaMessageSummary &message, + const CabanaSignalSummary &signal) { + CabanaSignalEditorState &editor = state->cabana_signal_editor; + editor.open = false; + editor.loaded = true; + editor.creating = false; + editor.message_root = message.root_path; + editor.message_name = message.name; + editor.service = message.service; + editor.signal_path = signal.path; + editor.bus = message.bus; + editor.message_address = message.address; + editor.original_signal_name = signal.name; + editor.signal_name = signal.name; + editor.start_bit = signal.start_bit; + editor.size = signal.size; + editor.factor = signal.factor; + editor.offset = signal.offset; + editor.min = signal.min; + editor.max = signal.max; + editor.is_signed = signal.is_signed; + editor.is_little_endian = signal.is_little_endian; + editor.type = signal.type; + editor.multiplex_value = signal.multiplex_value; + editor.receiver_name = signal.receiver_name; + editor.unit = signal.unit; +} + +void start_inline_signal_create(UiState *state, + const CabanaMessageSummary &message, + int start_bit, + int size, + bool is_little_endian) { + const int byte_index = start_bit / 8; + const int bit_index = start_bit & 7; + std::string base_name = "bit_" + std::to_string(byte_index) + "_" + std::to_string(bit_index); + std::string signal_name = base_name; + int suffix = 2; + auto exists = [&](std::string_view candidate) { + return std::any_of(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) { + return signal.name == candidate; + }); + }; + while (exists(signal_name)) { + signal_name = base_name + "_" + std::to_string(suffix++); + } + + CabanaSignalEditorState &editor = state->cabana_signal_editor; + editor.open = false; + editor.loaded = true; + editor.creating = true; + editor.message_root = message.root_path; + editor.message_name = message.name; + editor.service = message.service; + editor.signal_path.clear(); + editor.bus = message.bus; + editor.message_address = message.address; + editor.original_signal_name.clear(); + editor.signal_name = signal_name; + editor.start_bit = start_bit; + editor.size = size; + editor.factor = 1.0; + editor.offset = 0.0; + editor.min = 0.0; + editor.max = std::min(std::pow(2.0, static_cast(std::min(size, 24))) - 1.0, 1.0e9); + editor.is_signed = false; + editor.is_little_endian = is_little_endian; + editor.type = 0; + editor.multiplex_value = 0; + editor.receiver_name = "XXX"; + editor.unit.clear(); +} + +const char *signal_type_label(int type) { + switch (static_cast(type)) { + case dbc::Signal::Type::Normal: return "Normal"; + case dbc::Signal::Type::Multiplexed: return "Muxed"; + case dbc::Signal::Type::Multiplexor: return "Mux"; + } + return "?"; +} + +void draw_signal_list_header(UiState *state, const CabanaMessageSummary &message) { + const size_t charted = state->cabana.chart_signal_paths.size(); + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + ImGui::BeginChild("##cabana_signals_header", ImVec2(0.0f, 34.0f), false, ImGuiWindowFlags_NoScrollbar); + if (ImGui::BeginTable("##cabana_signal_header_layout", 3, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoPadOuterX | ImGuiTableFlags_NoPadInnerX)) { + ImGui::TableSetupColumn("##left", ImGuiTableColumnFlags_WidthFixed, 132.0f); + ImGui::TableSetupColumn("##filter", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##right", ImGuiTableColumnFlags_WidthFixed, 182.0f); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + app_push_bold_font(); + ImGui::Text("Signals: %zu", message.signals.size()); + app_pop_bold_font(); + if (charted > 0) { + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("%zu charted", charted); + } + + ImGui::TableSetColumnIndex(1); + const bool show_clear = state->cabana.signal_filter[0] != '\0'; + ImGui::SetNextItemWidth(show_clear ? -24.0f : -FLT_MIN); + ImGui::InputTextWithHint("##cabana_signal_filter", "Filter signal / unit / receiver", state->cabana.signal_filter.data(), + state->cabana.signal_filter.size()); + if (show_clear) { + ImGui::SameLine(0.0f, 4.0f); + if (ImGui::SmallButton("x##clear_signal_filter")) { + state->cabana.signal_filter[0] = '\0'; + } + } + + ImGui::TableSetColumnIndex(2); + ImGui::TextDisabled("%ds", state->cabana.sparkline_range_sec); + ImGui::SameLine(0.0f, 4.0f); + ImGui::SetNextItemWidth(76.0f); + ImGui::SliderInt("##cabana_sparkline_range", &state->cabana.sparkline_range_sec, 1, 30, ""); + ImGui::SameLine(0.0f, 6.0f); + if (ImGui::SmallButton("Collapse")) { + state->cabana.selected_signal_path.clear(); + state->cabana_signal_editor.loaded = false; + state->cabana_signal_editor.creating = false; + } + ImGui::EndTable(); + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::Spacing(); +} + +void draw_signal_inspector(AppSession *session, + UiState *state, + const CabanaMessageSummary &message, + const CabanaSignalSummary *selected_signal, + bool inline_mode = false) { + CabanaSignalEditorState &editor = state->cabana_signal_editor; + const bool showing_create = editor.loaded && editor.creating && editor.message_root == message.root_path; + if (selected_signal == nullptr && !showing_create) { + if (!inline_mode) { + draw_empty_panel("Signal Inspector", "Select a decoded signal to inspect and edit it."); + } + return; + } + if (selected_signal != nullptr + && (!editor.loaded || editor.creating || editor.message_root != message.root_path || editor.signal_path != selected_signal->path)) { + load_inline_signal_editor(state, message, *selected_signal); + } + + const bool charted = !editor.creating && !editor.signal_path.empty() && cabana_signal_charted(*state, editor.signal_path); + if (!inline_mode) { + draw_cabana_panel_title(showing_create ? "New Signal" : "Signal Inspector", + showing_create ? "Create and save directly into the active DBC" : std::string_view{}); + } + ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); + ImGui::BeginChild(inline_mode ? "##cabana_signal_editor_inline" : "##cabana_signal_editor", ImVec2(0.0f, 0.0f), true); + + app_push_bold_font(); + ImGui::TextUnformatted(showing_create ? "New Signal" : editor.signal_name.c_str()); + app_pop_bold_font(); + if (!editor.unit.empty()) { + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("[%s]", editor.unit.c_str()); + } + if (!showing_create && selected_signal != nullptr) { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextDisabled("%s", signal_type_label(selected_signal->type)); + ImGui::SameLine(0.0f, 10.0f); + const auto value = cabana_chart_value_label(*session, selected_signal->path, state->tracker_time); + ImGui::TextDisabled("value %s", value.c_str()); + } + if (!showing_create && !editor.signal_path.empty()) { + ImGui::SameLine(0.0f, 12.0f); + bool plot = charted; + if (ImGui::Checkbox("Plot", &plot)) { + toggle_cabana_signal_chart(state, editor.signal_path, plot, plot && !charted); + } + } + + if (selected_signal != nullptr && !selected_signal->comment.empty()) { + ImGui::TextWrapped("%s", selected_signal->comment.c_str()); + } else if (selected_signal != nullptr && selected_signal->value_description_count > 0) { + ImGui::TextDisabled("%d value description%s", selected_signal->value_description_count, + selected_signal->value_description_count == 1 ? "" : "s"); + } + ImGui::Spacing(); + + if (ImGui::BeginTable("##cabana_signal_editor_form", 2, + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_BordersInnerV)) { + ImGui::TableSetupColumn("##left", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableSetupColumn("##right", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + input_text_string("Name", &editor.signal_name, ImGuiInputTextFlags_AutoSelectAll); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputInt("Start Bit", &editor.start_bit); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputInt("Size", &editor.size); + ImGui::Checkbox("Little Endian", &editor.is_little_endian); + ImGui::Checkbox("Signed", &editor.is_signed); + + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputDouble("Factor", &editor.factor, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputDouble("Offset", &editor.offset, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputDouble("Min", &editor.min, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputDouble("Max", &editor.max, 0.0, 0.0, "%.6g"); + input_text_string("Unit", &editor.unit); + input_text_string("Receiver", &editor.receiver_name); + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + static constexpr const char *kTypes[] = {"Normal", "Multiplexed", "Multiplexor"}; + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::Combo("Type", &editor.type, kTypes, IM_ARRAYSIZE(kTypes)); + if (editor.type != 0) { + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputInt("Mux Value", &editor.multiplex_value); + } + ImGui::EndTable(); + } + + ImGui::Spacing(); + if (ImGui::Button("Apply", ImVec2(96.0f, 0.0f))) { + state->cabana.pending_apply_signal_edit = true; + } + ImGui::SameLine(); + if (ImGui::Button("Revert", ImVec2(96.0f, 0.0f))) { + if (selected_signal != nullptr) { + load_inline_signal_editor(state, message, *selected_signal); + } else if (showing_create) { + const int start_bit = state->cabana.has_bit_selection + ? state->cabana.selected_bit_byte * 8 + state->cabana.selected_bit_index + : 0; + start_inline_signal_create(state, message, start_bit, 1, true); + } + } + ImGui::SameLine(); + if (ImGui::Button("Raw DBC...", ImVec2(110.0f, 0.0f))) { + state->dbc_editor.open = true; + state->dbc_editor.loaded = false; + } + if (showing_create) { + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(96.0f, 0.0f))) { + editor.loaded = false; + editor.creating = false; + } + } + + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); +} + +} // namespace + +void draw_signal_panel(AppSession *session, UiState *state, const CabanaMessageSummary &message) { + draw_signal_list_header(state, message); + if (message.signals.empty() && !(state->cabana_signal_editor.loaded && state->cabana_signal_editor.creating)) { + draw_empty_panel("Signals", "No decoded signals for this message."); + return; + } + + const std::string filter = trim_copy(state->cabana.signal_filter.data()); + std::vector visible_indices; + visible_indices.reserve(message.signals.size()); + for (size_t i = 0; i < message.signals.size(); ++i) { + if (signal_matches_filter(message.signals[i], filter)) { + visible_indices.push_back(i); + } + } + + const CabanaSignalSummary *selected_signal = find_message_signal(message, state->cabana.selected_signal_path); + const bool show_create_editor = state->cabana_signal_editor.loaded + && state->cabana_signal_editor.creating + && state->cabana_signal_editor.message_root == message.root_path; + + ImGui::BeginChild("##cabana_signal_list", ImVec2(0.0f, 0.0f), false); + if (show_create_editor && selected_signal == nullptr) { + ImGui::BeginChild("##cabana_signal_create_inline", ImVec2(0.0f, 228.0f), false); + draw_signal_inspector(session, state, message, nullptr, true); + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + } + if (visible_indices.empty()) { + ImGui::TextDisabled("No signals match this filter."); + } else { + for (size_t row = 0; row < visible_indices.size(); ++row) { + const size_t visible_index = visible_indices[row]; + const CabanaSignalSummary &signal = message.signals[visible_index]; + const bool charted = cabana_signal_charted(*state, signal.path); + const bool selected = state->cabana.selected_signal_path == signal.path; + + ImGui::PushID(signal.path.c_str()); + const ImVec2 row_pos = ImGui::GetCursorScreenPos(); + const float row_w = ImGui::GetContentRegionAvail().x; + const float row_h = 28.0f; + ImGui::Dummy(ImVec2(row_w, row_h)); + const ImRect row_rect(row_pos, ImVec2(row_pos.x + row_w, row_pos.y + row_h)); + const bool row_hovered = ImGui::IsMouseHoveringRect(row_rect.Min, row_rect.Max); + ImDrawList *draw = ImGui::GetWindowDrawList(); + const ImU32 row_bg = ImGui::GetColorU32(selected ? color_rgb(66, 84, 117) : (row_hovered ? color_rgb(66, 68, 72) : color_rgb(58, 61, 64))); + const ImU32 row_border = ImGui::GetColorU32(selected ? color_rgb(108, 145, 214) : color_rgb(86, 90, 96)); + draw->AddRectFilled(row_rect.Min, row_rect.Max, row_bg, 3.0f); + draw->AddRect(row_rect.Min, row_rect.Max, row_border, 3.0f); + + const float button_w = 22.0f; + const float value_w = 82.0f; + const float spark_w = std::clamp(row_w * 0.28f, 110.0f, 180.0f); + const ImRect delete_rect(ImVec2(row_rect.Max.x - 6.0f - button_w, row_rect.Min.y + 3.0f), + ImVec2(row_rect.Max.x - 6.0f, row_rect.Max.y - 3.0f)); + const ImRect edit_rect(ImVec2(delete_rect.Min.x - 4.0f - button_w, row_rect.Min.y + 3.0f), + ImVec2(delete_rect.Min.x - 4.0f, row_rect.Max.y - 3.0f)); + const ImRect plot_rect(ImVec2(edit_rect.Min.x - 4.0f - button_w, row_rect.Min.y + 3.0f), + ImVec2(edit_rect.Min.x - 4.0f, row_rect.Max.y - 3.0f)); + const ImRect value_rect(ImVec2(plot_rect.Min.x - 8.0f - value_w, row_rect.Min.y), + ImVec2(plot_rect.Min.x - 8.0f, row_rect.Max.y)); + const ImRect spark_rect(ImVec2(std::max(row_rect.Min.x + 180.0f, value_rect.Min.x - 8.0f - spark_w), row_rect.Min.y + 2.0f), + ImVec2(value_rect.Min.x - 8.0f, row_rect.Max.y - 2.0f)); + + const float badge_x = row_rect.Min.x + 8.0f; + const ImRect badge_rect(ImVec2(badge_x, row_rect.Min.y + 4.0f), ImVec2(badge_x + 22.0f, row_rect.Max.y - 4.0f)); + const ImU32 badge_fill = ImGui::GetColorU32(color_rgb(kCabanaSignalPalette[visible_index % kCabanaSignalPalette.size()])); + draw->AddRectFilled(badge_rect.Min, badge_rect.Max, badge_fill, 3.0f); + const std::string ordinal = std::to_string(static_cast(row) + 1); + const ImVec2 ordinal_size = ImGui::CalcTextSize(ordinal.c_str()); + draw->AddText(ImVec2(badge_rect.Min.x + (badge_rect.GetWidth() - ordinal_size.x) * 0.5f, + badge_rect.Min.y + (badge_rect.GetHeight() - ordinal_size.y) * 0.5f - 1.0f), + ImGui::GetColorU32(IM_COL32_BLACK), ordinal.c_str()); + + float text_x = badge_rect.Max.x + 8.0f; + if (signal.type != 0) { + const std::string mux = signal.type == static_cast(dbc::Signal::Type::Multiplexor) + ? "M" + : ("m" + std::to_string(signal.multiplex_value)); + const ImVec2 mux_size = ImGui::CalcTextSize(mux.c_str()); + const ImRect mux_rect(ImVec2(text_x, row_rect.Min.y + 5.0f), + ImVec2(text_x + mux_size.x + 10.0f, row_rect.Max.y - 5.0f)); + draw->AddRectFilled(mux_rect.Min, mux_rect.Max, ImGui::GetColorU32(color_rgb(118, 122, 128)), 3.0f); + draw->AddText(ImVec2(mux_rect.Min.x + 5.0f, mux_rect.Min.y + (mux_rect.GetHeight() - mux_size.y) * 0.5f - 1.0f), + ImGui::GetColorU32(color_rgb(238, 240, 242)), + mux.c_str()); + text_x = mux_rect.Max.x + 8.0f; + } + + const ImRect name_rect(ImVec2(text_x, row_rect.Min.y), ImVec2(std::max(text_x, spark_rect.Min.x - 10.0f), row_rect.Max.y)); + const std::string name = signal.name; + const ImVec2 name_size = ImGui::CalcTextSize(name.c_str()); + const float name_max_w = std::max(0.0f, name_rect.GetWidth()); + std::string name_text = name; + if (name_size.x > name_max_w) { + name_text = name.substr(0, std::min(name.size(), static_cast(std::max(1.0f, name_max_w / 7.0f)))) + "..."; + } + draw->AddText(ImVec2(name_rect.Min.x, row_rect.Min.y + 6.0f), + ImGui::GetColorU32(color_rgb(225, 229, 233)), + name_text.c_str()); + + ImGui::SetCursorScreenPos(spark_rect.Min); + draw_signal_sparkline(*session, *state, signal.path, selected || charted, spark_rect.GetSize()); + draw->AddText(ImVec2(value_rect.Min.x, row_rect.Min.y + 6.0f), + ImGui::GetColorU32(color_rgb(207, 212, 218)), + cabana_chart_value_label(*session, signal.path, state->tracker_time).c_str()); + + auto draw_row_button = [&](const char *id, const ImRect &rect, const char *glyph, bool active, const char *tooltip) { + ImGui::SetCursorScreenPos(rect.Min); + ImGui::InvisibleButton(id, rect.GetSize()); + const bool hovered = ImGui::IsItemHovered(); + draw->AddRectFilled(rect.Min, rect.Max, + ImGui::GetColorU32(active ? cabana_accent() : (hovered ? color_rgb(82, 86, 91) : color_rgb(67, 70, 74))), + 3.0f); + draw->AddRect(rect.Min, rect.Max, ImGui::GetColorU32(color_rgb(96, 101, 108)), 3.0f); + ImVec2 text_size = ImGui::CalcTextSize(glyph); + draw->AddText(ImVec2(rect.Min.x + (rect.GetWidth() - text_size.x) * 0.5f, + rect.Min.y + (rect.GetHeight() - text_size.y) * 0.5f - 1.0f), + ImGui::GetColorU32(color_rgb(236, 239, 242)), + glyph); + if (hovered && tooltip != nullptr) { + ImGui::SetTooltip("%s", tooltip); + } + return ImGui::IsItemClicked(ImGuiMouseButton_Left); + }; + + const bool plot_clicked = draw_row_button("##plot", plot_rect, icon::BAR_CHART, charted, + charted ? "Close Plot" : "Show Plot"); + const bool edit_clicked = draw_row_button("##edit", edit_rect, icon::SLIDERS, false, "Inspect / Edit Signal"); + const bool delete_clicked = draw_row_button("##delete", delete_rect, "x", false, "Delete Signal"); + + if (plot_clicked) { + toggle_cabana_signal_chart(state, signal.path, !charted, !charted); + } + if (edit_clicked) { + if (selected) { + state->cabana.selected_signal_path.clear(); + state->cabana_signal_editor.loaded = false; + } else { + state->cabana.selected_signal_path = signal.path; + } + } + if (delete_clicked) { + load_inline_signal_editor(state, message, signal); + state->cabana.pending_delete_signal = true; + } + + const bool row_clickable = row_hovered + && !ImGui::IsMouseHoveringRect(plot_rect.Min, plot_rect.Max) + && !ImGui::IsMouseHoveringRect(edit_rect.Min, edit_rect.Max) + && !ImGui::IsMouseHoveringRect(delete_rect.Min, delete_rect.Max); + if (row_clickable && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (selected) { + state->cabana.selected_signal_path.clear(); + state->cabana_signal_editor.loaded = false; + } else { + state->cabana.selected_signal_path = signal.path; + } + } + if (row_clickable && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + toggle_cabana_signal_chart(state, signal.path, !charted, !charted); + } + if (selected) { + ImGui::Dummy(ImVec2(0.0f, 4.0f)); + ImGui::BeginChild("##cabana_signal_inline_editor", ImVec2(0.0f, 228.0f), false); + draw_signal_inspector(session, state, message, &signal, true); + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + } + ImGui::PopID(); + } + } + ImGui::EndChild(); +} diff --git a/tools/jotpluggler/app_camera.cc b/tools/jotpluggler/app_camera.cc new file mode 100644 index 00000000000000..a9e686eb915d86 --- /dev/null +++ b/tools/jotpluggler/app_camera.cc @@ -0,0 +1,54 @@ +#include "tools/jotpluggler/app_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/app_camera.h b/tools/jotpluggler/app_camera.h new file mode 100644 index 00000000000000..26b3c2acfb5ff5 --- /dev/null +++ b/tools/jotpluggler/app_camera.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/jotpluggler.h" + +void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane); diff --git a/tools/jotpluggler/app_common.cc b/tools/jotpluggler/app_common.cc new file mode 100644 index 00000000000000..94997bd574915b --- /dev/null +++ b/tools/jotpluggler/app_common.cc @@ -0,0 +1,197 @@ +#include "tools/jotpluggler/app_common.h" + +#include +#include +#include +#include + +namespace { + +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}, +}}; + +std::string format_coord(const GpsPoint &point) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.5f,%.5f", point.lat, point.lon); + return std::string(buf); +} + +} // namespace + +const std::array &camera_view_specs() { + return kCameraViewSpecs; +} + +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 std::array &special_item_specs() { + static const std::array specs = [] { + std::array out = {{ + {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {}, + {}, + {}, + {}, + }}; + for (size_t i = 0; i < kCameraViewSpecs.size(); ++i) { + out[i + 1] = SpecialItemSpec{ + kCameraViewSpecs[i].special_item_id, + kCameraViewSpecs[i].label, + PaneKind::Camera, + kCameraViewSpecs[i].view, + }; + } + return out; + }(); + return specs; +} + +const SpecialItemSpec *special_item_spec(std::string_view item_id) { + const auto &specs = special_item_specs(); + auto it = std::find_if(specs.begin(), specs.end(), [&](const SpecialItemSpec &spec) { + return item_id == spec.id; + }); + return it != specs.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 pane_is_special(const Pane &pane) { + return pane_kind_is_special(pane.kind); +} + +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; +} + +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) { + switch (type) { + case TimelineEntry::Type::Engaged: + return "engaged"; + case TimelineEntry::Type::AlertInfo: + return "alert info"; + case TimelineEntry::Type::AlertWarning: + return "alert warning"; + case TimelineEntry::Type::AlertCritical: + return "alert critical"; + case TimelineEntry::Type::None: + default: + return "disengaged"; + } +} + +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; +} + +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(trim_copy(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 + const int ret = std::system(command.c_str()); + (void)ret; +} + +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/app_common.h b/tools/jotpluggler/app_common.h new file mode 100644 index 00000000000000..802a2161725fa1 --- /dev/null +++ b/tools/jotpluggler/app_common.h @@ -0,0 +1,47 @@ +#pragma once + +#include "tools/jotpluggler/jotpluggler.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; +}; + +const std::array &camera_view_specs(); +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 std::array &special_item_specs(); +const SpecialItemSpec *special_item_spec(std::string_view item_id); +const char *special_item_label(std::string_view item_id); + +bool pane_is_special(const Pane &pane); +bool pane_kind_is_special(PaneKind kind); +bool is_default_special_title(std::string_view title); +CameraViewKind sidebar_preview_camera_view(const AppSession &session); + +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); + +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/app_custom_series.cc b/tools/jotpluggler/app_custom_series.cc new file mode 100644 index 00000000000000..1b8dbc7891ca56 --- /dev/null +++ b/tools/jotpluggler/app_custom_series.cc @@ -0,0 +1,801 @@ +#include "tools/jotpluggler/jotpluggler.h" + +#include "implot.h" + +#include +#include +#include +#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; +}; + +struct ProcessResult { + int exit_code = 0; + std::string stderr_text; +}; + +ProcessResult run_process(const std::vector &args) { + std::string command; + for (const std::string &arg : args) { + if (!command.empty()) command += ' '; + command += shell_quote(arg); + } + command += " 2>&1"; + ProcessResult result; + FILE *pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) throw std::runtime_error("popen() failed"); + std::array buf = {}; + while (fgets(buf.data(), static_cast(buf.size()), pipe) != nullptr) { + result.stderr_text += buf.data(); + } + const int status = pclose(pipe); + result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1; + return result; +} + +fs::path math_eval_script_path() { +#ifdef JOTP_REPO_ROOT + return fs::path(JOTP_REPO_ROOT) / "tools" / "jotpluggler" / "math_eval.py"; +#else + std::array buf = {}; + const ssize_t length = ::readlink("/proc/self/exe", buf.data(), buf.size() - 1); + if (length <= 0) throw std::runtime_error("Failed to resolve executable path"); + buf[static_cast(length)] = '\0'; + return fs::path(buf.data()).parent_path() / "math_eval.py"; +#endif +} + +void write_binary_vector(const fs::path &path, const std::vector &values) { + std::ofstream out(path, std::ios::binary); + if (!out) throw std::runtime_error("Failed to open " + path.string() + " for writing"); + if (!values.empty()) { + out.write(reinterpret_cast(values.data()), + static_cast(values.size() * sizeof(double))); + } + if (!out) throw std::runtime_error("Failed to write " + path.string()); +} + +std::vector read_binary_vector(const fs::path &path) { + std::ifstream in(path, std::ios::binary); + if (!in) throw std::runtime_error("Failed to open " + path.string()); + in.seekg(0, std::ios::end); + const std::streamoff size = in.tellg(); + in.seekg(0, std::ios::beg); + if (size < 0 || size % static_cast(sizeof(double)) != 0) { + throw std::runtime_error("Invalid binary series file: " + path.string()); + } + std::vector values(static_cast(size) / sizeof(double)); + if (!values.empty()) { + in.read(reinterpret_cast(values.data()), size); + } + if (!in) throw std::runtime_error("Failed to read " + path.string()); + return values; +} + +void write_text_file(const fs::path &path, std::string_view text) { + std::ofstream out(path); + if (!out) throw std::runtime_error("Failed to open " + path.string() + " for writing"); + out << 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 ProcessResult process = run_process({ + "python3", + math_eval_script_path().string(), + manifest_path.string(), + globals_path.string(), + code_path.string(), + out_t_path.string(), + out_v_path.string(), + }); + if (process.exit_code != 0) { + throw std::runtime_error(trim_copy(process.stderr_text).empty() ? "Python evaluation failed" + : trim_copy(process.stderr_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 && err_text.rfind("Missing route series ", 0) == 0) { + 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 = trim_copy(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 = trim_copy(editor->name); + editor->linked_source = trim_copy(editor->linked_source); + for (std::string &path : editor->additional_sources) { + path = trim_copy(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/app_internal.h b/tools/jotpluggler/app_internal.h new file mode 100644 index 00000000000000..c2cbe86a514c8e --- /dev/null +++ b/tools/jotpluggler/app_internal.h @@ -0,0 +1,192 @@ +#pragma once + +#include "tools/jotpluggler/app_common.h" +#include "tools/jotpluggler/app_map.h" + +#include +#include +#include + +namespace fs = std::filesystem; +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; +}; + +fs::path resolve_layout_path(const std::string &layout_arg); +fs::path autosave_path_for_layout(const fs::path &layout_path); +std::vector available_layout_names(); +void run_or_throw(const std::string &command, const std::string &action); + +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); +bool apply_cabana_signal_edit(AppSession *session, UiState *state); +bool apply_cabana_signal_delete(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(); +ImVec4 cabana_window_bg(); +ImVec4 cabana_panel_bg(); +ImVec4 cabana_panel_alt_bg(); +ImVec4 cabana_border_color(); +ImVec4 cabana_accent(); +ImVec4 cabana_accent_hover(); +ImVec4 cabana_accent_active(); +ImVec4 cabana_muted_text(); +void push_cabana_mode_style(); +void pop_cabana_mode_style(); +void draw_cabana_panel_title(const char *title, std::string_view subtitle = {}); +bool draw_cabana_bottom_tab(const char *id, const char *label, bool active, float width); +void draw_cabana_detail_tab_strip(UiState *state); +void draw_cabana_welcome_panel(); +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)); +void draw_signal_overlay_legend(const std::vector> &highlighted); +ImU32 mix_color(ImU32 a, ImU32 b, float t); +void draw_empty_panel(const char *title, const char *message); +void draw_cabana_toolbar_button(const char *label, bool enabled, const std::function &on_click); +void draw_cabana_warning_banner(const std::vector &warnings); +void draw_chart_panel(AppSession *session, UiState *state, const CabanaMessageSummary *message); +void draw_signal_panel(AppSession *session, UiState *state, const CabanaMessageSummary &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); +void draw_cabana_mode(AppSession *session, const UiMetrics &ui, UiState *state); +void rebuild_cabana_messages(AppSession *session); + +// app_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); +std::string layout_combo_label(const AppSession &session, const UiState &state); +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 fs::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 fs::path *capture_path); diff --git a/tools/jotpluggler/app_layout_flow.cc b/tools/jotpluggler/app_layout_flow.cc new file mode 100644 index 00000000000000..6a914491c55515 --- /dev/null +++ b/tools/jotpluggler/app_layout_flow.cc @@ -0,0 +1,1138 @@ +#include "tools/jotpluggler/app_internal.h" +#include "tools/jotpluggler/app_socketcan.h" +#include "tools/cabana/panda.h" +#include "system/hardware/hw.h" + +#include +#include +#include + +namespace { + +enum class ModalAction { + None, + Primary, + Secondary, +}; + +struct FindSignalMatch { + const std::string *path = nullptr; + int score = 0; +}; + +template +void copy_string_to_buffer(const std::string &value, std::array *buffer) { + std::snprintf(buffer->data(), buffer->size(), "%s", value.c_str()); +} + +constexpr int kPandaCanSpeeds[] = {10, 20, 50, 100, 125, 250, 500, 1000}; +constexpr int kPandaDataSpeeds[] = {10, 20, 50, 100, 125, 250, 500, 1000, 2000, 5000}; + +std::vector list_panda_serials() { + try { + return Panda::list(); + } catch (...) { + return {}; + } +} + +std::string stream_source_target_label(const StreamSourceConfig &source) { + switch (source.kind) { + case StreamSourceKind::CerealRemote: + return source.address.empty() ? std::string("127.0.0.1") : source.address; + case StreamSourceKind::Panda: + return source.panda.serial.empty() ? std::string("auto") : source.panda.serial; + case StreamSourceKind::SocketCan: + return source.socketcan.device.empty() ? std::string("can0") : source.socketcan.device; + case StreamSourceKind::CerealLocal: + default: + return "127.0.0.1"; + } +} + +StreamSourceConfig stream_source_config_from_ui(const UiState &state) { + StreamSourceConfig source; + source.kind = state.stream_source_kind; + source.address = trim_copy(state.stream_address_buffer.data()); + source.panda.serial = trim_copy(state.panda_serial_buffer.data()); + source.socketcan.device = trim_copy(state.socketcan_device_buffer.data()); + for (size_t i = 0; i < source.panda.buses.size(); ++i) { + source.panda.buses[i].can_speed_kbps = state.panda_can_speed_kbps[i]; + source.panda.buses[i].data_speed_kbps = state.panda_data_speed_kbps[i]; + source.panda.buses[i].can_fd = state.panda_can_fd[i]; + } + if (source.kind == StreamSourceKind::CerealLocal) { + source.address = "127.0.0.1"; + } else if (source.kind == StreamSourceKind::SocketCan && source.socketcan.device.empty()) { + source.socketcan.device = "can0"; + } + 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(query); + for (const std::string &path : session.route_data.paths) { + const std::string hay = lowercase(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(label) == needle) score -= 60; + if (hay.rfind(needle, 0) == 0) 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(AppSession *session, UiState *state, const std::string &path) { + for (const CabanaMessageSummary &message : session->cabana_messages) { + const auto signal_it = std::find_if(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) { + return signal.path == path; + }); + if (signal_it != message.signals.end()) { + state->view_mode = AppViewMode::Cabana; + state->cabana.selected_message_root = message.root_path; + state->cabana.selected_signal_path = path; + state->cabana.has_bit_selection = false; + state->cabana.similar_bit_matches.clear(); + state->status_text = "Opened signal in Cabana"; + return true; + } + } + + state->view_mode = AppViewMode::Plot; + 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(); + ImGui::InputText("Route", state->route_buffer.data(), state->route_buffer.size()); + ImGui::InputText("Data Dir", state->data_dir_buffer.data(), state->data_dir_buffer.size()); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + reload_session(session, state, std::string(state->route_buffer.data()), std::string(state->data_dir_buffer.data())); + 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; + } + static std::vector panda_serials; + static std::vector socketcan_devices; + + 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 (ImGui::RadioButton("Panda", state->stream_source_kind == StreamSourceKind::Panda)) { + state->stream_source_kind = StreamSourceKind::Panda; + if (panda_serials.empty()) panda_serials = list_panda_serials(); + } +#ifdef __linux__ + if (ImGui::RadioButton("SocketCAN", state->stream_source_kind == StreamSourceKind::SocketCan)) { + state->stream_source_kind = StreamSourceKind::SocketCan; + if (socketcan_devices.empty()) socketcan_devices = list_socketcan_devices(); + } +#else + ImGui::BeginDisabled(true); + ImGui::RadioButton("SocketCAN", false); + ImGui::EndDisabled(); +#endif + + if (state->stream_source_kind == StreamSourceKind::CerealRemote) { + ImGui::InputText("Address", state->stream_address_buffer.data(), state->stream_address_buffer.size()); + } else if (state->stream_source_kind == StreamSourceKind::Panda) { + if (ImGui::Button("Refresh Pandas")) { + panda_serials = list_panda_serials(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu found", panda_serials.size()); + if (ImGui::BeginCombo("Serial", state->panda_serial_buffer.data()[0] == '\0' ? "auto" : state->panda_serial_buffer.data())) { + const bool auto_selected = state->panda_serial_buffer.data()[0] == '\0'; + if (ImGui::Selectable("auto", auto_selected)) { + state->panda_serial_buffer[0] = '\0'; + } + for (const std::string &serial : panda_serials) { + const bool selected = serial == state->panda_serial_buffer.data(); + if (ImGui::Selectable(serial.c_str(), selected)) { + copy_string_to_buffer(serial, &state->panda_serial_buffer); + } + } + ImGui::EndCombo(); + } + for (int bus = 0; bus < 3; ++bus) { + ImGui::PushID(bus); + ImGui::SeparatorText((std::string("Bus ") + std::to_string(bus)).c_str()); + if (ImGui::BeginCombo("CAN Speed", (std::to_string(state->panda_can_speed_kbps[bus]) + " kbps").c_str())) { + for (const int speed : kPandaCanSpeeds) { + const bool selected = speed == state->panda_can_speed_kbps[bus]; + if (ImGui::Selectable((std::to_string(speed) + " kbps").c_str(), selected)) { + state->panda_can_speed_kbps[bus] = speed; + } + } + ImGui::EndCombo(); + } + ImGui::Checkbox("CAN-FD", &state->panda_can_fd[bus]); + ImGui::BeginDisabled(!state->panda_can_fd[bus]); + if (ImGui::BeginCombo("Data Speed", (std::to_string(state->panda_data_speed_kbps[bus]) + " kbps").c_str())) { + for (const int speed : kPandaDataSpeeds) { + const bool selected = speed == state->panda_data_speed_kbps[bus]; + if (ImGui::Selectable((std::to_string(speed) + " kbps").c_str(), selected)) { + state->panda_data_speed_kbps[bus] = speed; + } + } + ImGui::EndCombo(); + } + ImGui::EndDisabled(); + ImGui::PopID(); + } + } else if (state->stream_source_kind == StreamSourceKind::SocketCan) { +#ifdef __linux__ + if (ImGui::Button("Refresh Devices")) { + socketcan_devices = list_socketcan_devices(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu found", socketcan_devices.size()); + if (ImGui::BeginCombo("Device", state->socketcan_device_buffer.data()[0] == '\0' ? "can0" : state->socketcan_device_buffer.data())) { + for (const std::string &device : socketcan_devices) { + const bool selected = device == state->socketcan_device_buffer.data(); + if (ImGui::Selectable(device.c_str(), selected)) { + copy_string_to_buffer(device, &state->socketcan_device_buffer); + } + } + ImGui::EndCombo(); + } +#else + ImGui::TextDisabled("SocketCAN is only available on Linux."); +#endif + } + 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(); + ImGui::InputText("Layout", state->load_layout_buffer.data(), state->load_layout_buffer.size()); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + if (reload_layout(session, state, std::string(state->load_layout_buffer.data()))) { + 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(); + ImGui::InputText("Layout", state->save_layout_buffer.data(), state->save_layout_buffer.size()); + ImGui::Spacing(); + switch (draw_modal_action_row("Save")) { + case ModalAction::Primary: + if (save_layout(session, state, std::string(state->save_layout_buffer.data()))) { + 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); + ImGui::InputTextWithHint("##find_signal_query", "Search signal path or name...", state->find_signal_buffer.data(), state->find_signal_buffer.size()); + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(-1); + } + const std::vector matches = find_signal_matches(*session, state->find_signal_buffer.data()); + 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(session, 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(); +} + +const fs::path &repo_root() { + static const fs::path root = []() { +#ifdef JOTP_REPO_ROOT + return fs::path(JOTP_REPO_ROOT); +#else + return fs::current_path(); +#endif + }(); + return root; +} + +std::string default_dbc_template() { + return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n"; +} + +std::string active_dbc_name(const AppSession &session) { + return !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name; +} + +fs::path generated_dbc_dir() { + return repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; +} + +fs::path resolve_dbc_editor_source(const std::string &dbc_name) { + for (const fs::path &candidate : { + repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), + generated_dbc_dir() / (dbc_name + ".dbc"), + }) { + if (fs::exists(candidate)) { + return candidate; + } + } + return {}; +} + +std::string read_text_file(const fs::path &path) { + std::ifstream in(path); + if (!in.is_open()) { + throw std::runtime_error("Failed to open " + path.string()); + } + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void load_dbc_editor_state(const AppSession &session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + const std::string dbc_name = active_dbc_name(session); + editor.source_name = dbc_name.empty() ? "untitled" : dbc_name; + editor.source_path.clear(); + if (dbc_name.empty()) { + editor.save_name = "custom_can"; + editor.text = default_dbc_template(); + } else { + const fs::path path = resolve_dbc_editor_source(dbc_name); + editor.source_path = path.string(); + editor.text = path.empty() ? default_dbc_template() : read_text_file(path); + editor.save_name = path.string().find("/generated_dbcs/") != std::string::npos ? 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; +} + +std::string multiplex_indicator_for_signal(const CabanaSignalEditorState &signal) { + if (signal.type == static_cast(dbc::Signal::Type::Multiplexor)) { + return "M "; + } + if (signal.type == static_cast(dbc::Signal::Type::Multiplexed)) { + return "m" + std::to_string(signal.multiplex_value) + " "; + } + return {}; +} + +std::string build_signal_definition_line(const CabanaSignalEditorState &signal) { + return " SG_ " + signal.signal_name + " " + multiplex_indicator_for_signal(signal) + ": " + + std::to_string(signal.start_bit) + "|" + std::to_string(signal.size) + "@" + + std::string(1, signal.is_little_endian ? '1' : '0') + + std::string(1, signal.is_signed ? '-' : '+') + + " (" + util::string_format("%.15g", signal.factor) + "," + util::string_format("%.15g", signal.offset) + ")" + + " [" + util::string_format("%.15g", signal.min) + "|" + util::string_format("%.15g", signal.max) + "]" + + " \"" + signal.unit + "\" " + (signal.receiver_name.empty() ? "XXX" : signal.receiver_name); +} + +bool replace_signal_line(std::string *text, + uint32_t address, + const std::string &signal_name, + const std::string &replacement) { + std::istringstream in(*text); + std::string line; + std::string out; + bool in_message = false; + bool replaced = false; + while (std::getline(in, line)) { + const std::string trimmed = trim_copy(line); + if (trimmed.rfind("BO_ ", 0) == 0) { + char *end = nullptr; + const long parsed = std::strtol(trimmed.c_str() + 4, &end, 10); + in_message = end != nullptr && parsed == static_cast(address); + } else if (in_message && trimmed.rfind("SG_ ", 0) == 0) { + const size_t name_start = 4; + const size_t name_end = trimmed.find(' ', name_start); + const std::string current_name = name_end == std::string::npos ? trimmed.substr(name_start) : trimmed.substr(name_start, name_end - name_start); + if (current_name == signal_name) { + out += replacement + "\n"; + replaced = true; + continue; + } + } + out += line + "\n"; + } + if (replaced) { + *text = std::move(out); + } + return replaced; +} + +bool insert_signal_line(std::string *text, + uint32_t address, + const std::string &line_to_insert) { + std::istringstream in(*text); + std::string line; + std::string out; + bool in_message = false; + bool inserted = false; + while (std::getline(in, line)) { + const std::string trimmed = trim_copy(line); + if (trimmed.rfind("BO_ ", 0) == 0) { + if (in_message && !inserted) { + out += line_to_insert + "\n"; + inserted = true; + } + char *end = nullptr; + const long parsed = std::strtol(trimmed.c_str() + 4, &end, 10); + in_message = end != nullptr && parsed == static_cast(address); + out += line + "\n"; + continue; + } + if (in_message && !inserted) { + const bool starts_new_top_level = !trimmed.empty() && trimmed.rfind("SG_ ", 0) != 0; + if (starts_new_top_level) { + out += line_to_insert + "\n"; + inserted = true; + } + } + out += line + "\n"; + } + if (in_message && !inserted) { + out += line_to_insert + "\n"; + inserted = true; + } + if (inserted) { + *text = std::move(out); + } + return inserted; +} + +bool remove_signal_line(std::string *text, + uint32_t address, + const std::string &signal_name) { + std::istringstream in(*text); + std::string line; + std::string out; + bool in_message = false; + bool removed = false; + while (std::getline(in, line)) { + const std::string trimmed = trim_copy(line); + if (trimmed.rfind("BO_ ", 0) == 0) { + char *end = nullptr; + const long parsed = std::strtol(trimmed.c_str() + 4, &end, 10); + in_message = end != nullptr && parsed == static_cast(address); + } else if (in_message && trimmed.rfind("SG_ ", 0) == 0) { + const size_t name_start = 4; + const size_t name_end = trimmed.find(' ', name_start); + const std::string current_name = name_end == std::string::npos ? trimmed.substr(name_start) : trimmed.substr(name_start, name_end - name_start); + if (current_name == signal_name) { + removed = true; + continue; + } + } + out += line + "\n"; + } + if (removed) { + *text = std::move(out); + } + return removed; +} + +bool save_dbc_editor_contents(AppSession *session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + editor.save_name = trim_copy(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_path.empty() + && editor.source_path.find("/opendbc/dbc/") != std::string::npos + && 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"); + fs::create_directories(generated_dbc_dir()); + const fs::path output = generated_dbc_dir() / (editor.save_name + ".dbc"); + std::ofstream out(output); + if (!out.is_open()) { + throw std::runtime_error("Failed to open " + output.string()); + } + out << editor.text; + if (!out.good()) { + throw std::runtime_error("Failed while writing " + output.string()); + } + apply_dbc_override_change(session, state, editor.save_name); + editor.source_name = editor.save_name; + editor.source_path = output.string(); + 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; + } +} + +bool apply_cabana_signal_edit_impl(AppSession *session, UiState *state) { + if (!ensure_dbc_editor_loaded(*session, state)) { + return false; + } + CabanaSignalEditorState &signal = state->cabana_signal_editor; + if (trim_copy(signal.signal_name).empty()) { + state->error_text = "Signal name cannot be empty"; + state->open_error_popup = true; + return false; + } + signal.signal_name = trim_copy(signal.signal_name); + signal.receiver_name = trim_copy(signal.receiver_name); + if (signal.size <= 0) { + state->error_text = "Signal size must be positive"; + state->open_error_popup = true; + return false; + } + if (signal.creating) { + if (!insert_signal_line(&state->dbc_editor.text, + signal.message_address, + build_signal_definition_line(signal))) { + state->error_text = "Failed to locate message in DBC text"; + state->open_error_popup = true; + return false; + } + } else { + if (!replace_signal_line(&state->dbc_editor.text, + signal.message_address, + signal.original_signal_name, + build_signal_definition_line(signal))) { + state->error_text = "Failed to locate signal in DBC text"; + state->open_error_popup = true; + return false; + } + } + if (save_dbc_editor_contents(session, state)) { + const std::string old_path = signal.creating + ? std::string() + : "/" + signal.service + "/" + std::to_string(signal.bus) + "/" + signal.message_name + "/" + signal.original_signal_name; + const std::string new_path = "/" + signal.service + "/" + std::to_string(signal.bus) + "/" + signal.message_name + "/" + signal.signal_name; + state->cabana_signal_editor.open = false; + state->cabana_signal_editor.loaded = false; + state->cabana.selected_message_root = signal.message_root; + state->cabana.selected_signal_path = new_path; + bool replaced_chart = false; + if (!old_path.empty()) { + for (std::string &path : state->cabana.chart_signal_paths) { + if (path == old_path) { + path = new_path; + replaced_chart = true; + } + } + } + if (!signal.creating && !replaced_chart) { + state->cabana.chart_signal_paths.erase( + std::remove(state->cabana.chart_signal_paths.begin(), state->cabana.chart_signal_paths.end(), new_path), + state->cabana.chart_signal_paths.end()); + } + state->status_text = std::string(signal.creating ? "Created signal " : "Updated signal ") + signal.signal_name; + return true; + } + return false; +} + +bool apply_cabana_signal_delete_impl(AppSession *session, UiState *state) { + if (!ensure_dbc_editor_loaded(*session, state)) { + return false; + } + CabanaSignalEditorState &signal = state->cabana_signal_editor; + if (signal.creating || trim_copy(signal.original_signal_name).empty()) { + state->error_text = "No existing signal selected for deletion"; + state->open_error_popup = true; + return false; + } + if (!remove_signal_line(&state->dbc_editor.text, signal.message_address, signal.original_signal_name)) { + state->error_text = "Failed to locate signal in DBC text"; + state->open_error_popup = true; + return false; + } + if (save_dbc_editor_contents(session, state)) { + const std::string old_path = "/" + signal.service + "/" + std::to_string(signal.bus) + "/" + signal.message_name + "/" + signal.original_signal_name; + state->cabana_signal_editor.open = false; + state->cabana_signal_editor.loaded = false; + state->cabana.selected_message_root = signal.message_root; + if (state->cabana.selected_signal_path == old_path) { + state->cabana.selected_signal_path.clear(); + } + state->cabana.chart_signal_paths.erase( + std::remove(state->cabana.chart_signal_paths.begin(), state->cabana.chart_signal_paths.end(), old_path), + state->cabana.chart_signal_paths.end()); + state->status_text = "Deleted signal " + signal.original_signal_name; + return 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.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_cabana_signal_editor_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Edit CAN Signal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + CabanaSignalEditorState &signal = state->cabana_signal_editor; + ImGui::TextUnformatted(signal.creating + ? "Create a decoded signal from the selected bit and save it into generated_dbcs." + : "Edit the selected decoded signal and save it into generated_dbcs."); + ImGui::Separator(); + input_text_string("Name", &signal.signal_name, ImGuiInputTextFlags_AutoSelectAll); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputInt("Start Bit", &signal.start_bit); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputInt("Size", &signal.size); + ImGui::Checkbox("Little Endian", &signal.is_little_endian); + ImGui::Checkbox("Signed", &signal.is_signed); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputDouble("Factor", &signal.factor, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputDouble("Offset", &signal.offset, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputDouble("Min", &signal.min, 0.0, 0.0, "%.6g"); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputDouble("Max", &signal.max, 0.0, 0.0, "%.6g"); + input_text_string("Unit", &signal.unit); + input_text_string("Receiver", &signal.receiver_name); + ImGui::Spacing(); + if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) { + if (apply_cabana_signal_edit_impl(session, state)) { + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Open Raw DBC Editor", ImVec2(170.0f, 0.0f))) { + state->dbc_editor.open = true; + } + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + state->cabana_signal_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 apply_cabana_signal_edit(AppSession *session, UiState *state) { + return apply_cabana_signal_edit_impl(session, state); +} + +bool apply_cabana_signal_delete(AppSession *session, UiState *state) { + return apply_cabana_signal_delete_impl(session, state); +} + +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->cabana_signal_editor.open, "Edit CAN Signal"); + 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_cabana_signal_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/app_layout_io.cc b/tools/jotpluggler/app_layout_io.cc new file mode 100644 index 00000000000000..73f1ca92830fff --- /dev/null +++ b/tools/jotpluggler/app_layout_io.cc @@ -0,0 +1,131 @@ +#include "tools/jotpluggler/jotpluggler.h" +#include "tools/jotpluggler/app_common.h" + +#include +#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}, + }; + std::ofstream out(path); + if (!out) throw std::runtime_error("Failed to open layout for writing: " + path.string()); + out << root.dump() << "\n"; +} diff --git a/tools/jotpluggler/app_logs.cc b/tools/jotpluggler/app_logs.cc new file mode 100644 index 00000000000000..129568a206a0d7 --- /dev/null +++ b/tools/jotpluggler/app_logs.cc @@ -0,0 +1,427 @@ +#include "tools/jotpluggler/jotpluggler.h" + +#include +#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(query); + const auto contains = [&](std::string_view haystack) { + return lowercase(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; + char buf[32] = {}; + std::snprintf(buf, sizeof(buf), "%d:%06.3f", minutes, remaining); + return buf; +} + +std::string format_boot_time(double seconds) { + char buf[32] = {}; + std::snprintf(buf, sizeof(buf), "%.3f", seconds); + return buf; +} + +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)); + char buf[32] = {}; + std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d.%03d", + wall_tm.tm_hour, wall_tm.tm_min, wall_tm.tm_sec, millis); + return buf; +} + +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/app_map.cc b/tools/jotpluggler/app_map.cc new file mode 100644 index 00000000000000..a4a65f222351fd --- /dev/null +++ b/tools/jotpluggler/app_map.cc @@ -0,0 +1,1415 @@ +#include "tools/jotpluggler/jotpluggler.h" +#include "tools/jotpluggler/app_common.h" +#include "tools/jotpluggler/app_map.h" + +#include + +extern "C" { +#include +} + +#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" + +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 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 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) { + char buf[128]; + std::snprintf(buf, sizeof(buf), "v2_%.5f_%.5f_%.5f_%.5f", + bounds.south, bounds.west, bounds.north, bounds.east); + return buf; +} + +fs::path basemap_cache_path(const std::string &key) { + const uint64_t hash = fnv1a64(key); + char buf[32]; + std::snprintf(buf, sizeof(buf), "%016llx.bin.zst", static_cast(hash)); + return basemap_cache_root() / buf; +} + +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 read_binary_file(const fs::path &path) { + std::ifstream in(path, std::ios::binary); + if (!in) { + return {}; + } + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void write_binary_file(const fs::path &path, const std::string &contents) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (out) { + out.write(contents.data(), static_cast(contents.size())); + } +} + +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 { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%%%02X", static_cast(c)); + out += buf; + } + } + return out; +} + +std::string bbox_string(const GeoBounds &bounds) { + char bbox[128]; + std::snprintf(bbox, sizeof(bbox), "%.6f,%.6f,%.6f,%.6f", + bounds.south, bounds.west, bounds.north, bounds.east); + return bbox; +} + +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 (raw.size() < 4 || raw.substr(0, 4) != "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); + write_binary_file(path, compressed); + return true; +} + +std::optional load_compressed_basemap(const fs::path &path, const std::string &key) { + const std::string compressed = read_binary_file(path); + 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 + +struct MapDataManager::Impl { + struct Request { + std::string key; + GeoBounds bounds; + std::string query; + }; + + Impl() : worker([this]() { run(); }) {} + + ~Impl() { + { + std::lock_guard lock(mutex); + stopping = true; + } + cv.notify_all(); + if (worker.joinable()) { + worker.join(); + } + } + + void 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) { + return; + } + if (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 = Request{ + .key = wanted.key, + .bounds = wanted.bounds, + .query = wanted.query, + }; + cv.notify_one(); + } + + void pump() { + std::optional ready; + { + std::lock_guard lock(mutex); + if (completed) { + ready = std::move(completed); + completed.reset(); + } + } + if (ready) { + current = std::make_unique(std::move(*ready)); + } + } + + bool loading() const { + std::lock_guard lock(mutex); + return active || pending; + } + + void clearCache() { + std::lock_guard lock(mutex); + clear_cache_directory(); + } + + MapCacheStats cacheStats() const { + return MapCacheStats{ + .bytes = cache_directory_size_bytes(), + .files = cache_directory_file_count(), + }; + } + + const RouteBasemap *currentData() const { + return current.get(); + } + + void run() { + while (true) { + Request request; + { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { return stopping || pending.has_value(); }); + if (stopping) { + return; + } + request = *pending; + active = pending; + pending.reset(); + } + + std::optional parsed; + const std::string raw = load_overpass_json(request.query); + if (!raw.empty()) { + parsed = parse_basemap_json(raw, request.bounds, request.key); + if (parsed) { + save_compressed_basemap(basemap_cache_path(request.key), *parsed); + } + } + + { + std::lock_guard lock(mutex); + if (active && active->key == request.key) { + completed = std::move(parsed); + active.reset(); + } + } + } + } + + mutable std::mutex mutex; + std::condition_variable cv; + bool stopping = false; + std::optional pending; + std::optional active; + std::optional completed; + std::unique_ptr current; + std::thread worker; +}; + +MapDataManager::MapDataManager() : impl_(std::make_unique()) {} +MapDataManager::~MapDataManager() = default; + +void MapDataManager::pump() { + impl_->pump(); +} + +void MapDataManager::ensureTrace(const GpsTrace &trace) { + impl_->ensureTrace(trace); +} + +bool MapDataManager::loading() const { + return impl_->loading(); +} + +const RouteBasemap *MapDataManager::current() const { + return impl_->currentData(); +} + +void MapDataManager::clearCache() { + impl_->clearCache(); +} + +MapCacheStats MapDataManager::cacheStats() const { + return impl_->cacheStats(); +} + +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/app_map.h b/tools/jotpluggler/app_map.h new file mode 100644 index 00000000000000..14e35b92def40f --- /dev/null +++ b/tools/jotpluggler/app_map.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +struct GpsTrace; +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 Impl; + std::unique_ptr impl_; +}; diff --git a/tools/jotpluggler/app_plot.cc b/tools/jotpluggler/app_plot.cc new file mode 100644 index 00000000000000..626c6a507b02e9 --- /dev/null +++ b/tools/jotpluggler/app_plot.cc @@ -0,0 +1,1028 @@ +#include "tools/jotpluggler/app_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; + + const bool cabana_mode = state->view_mode == AppViewMode::Cabana; + ImPlot::PushStyleColor(ImPlotCol_PlotBg, cabana_mode ? color_rgb(52, 54, 57) : color_rgb(255, 255, 255)); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, cabana_mode ? color_rgb(95, 100, 106) : color_rgb(186, 190, 196)); + ImPlot::PushStyleColor(ImPlotCol_LegendBg, cabana_mode ? color_rgb(46, 47, 49, 0.94f) : color_rgb(248, 249, 251, 0.92f)); + ImPlot::PushStyleColor(ImPlotCol_LegendBorder, cabana_mode ? color_rgb(95, 100, 106) : color_rgb(168, 175, 184)); + ImPlot::PushStyleColor(ImPlotCol_LegendText, cabana_mode ? color_rgb(220, 224, 229) : color_rgb(57, 62, 69)); + ImPlot::PushStyleColor(ImPlotCol_TitleText, cabana_mode ? color_rgb(220, 224, 229) : color_rgb(57, 62, 69)); + ImPlot::PushStyleColor(ImPlotCol_InlayText, cabana_mode ? color_rgb(214, 219, 225) : color_rgb(95, 103, 112)); + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, cabana_mode ? color_rgb(86, 90, 96) : color_rgb(188, 196, 206)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, cabana_mode ? color_rgb(182, 188, 196) : color_rgb(95, 103, 112)); + ImPlot::PushStyleColor(ImPlotCol_AxisBg, cabana_mode ? color_rgb(60, 63, 65, 0.0f) : color_rgb(255, 255, 255, 0.0f)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgHovered, cabana_mode ? color_rgb(78, 82, 88, 0.38f) : color_rgb(214, 220, 228, 0.45f)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgActive, cabana_mode ? color_rgb(92, 98, 106, 0.48f) : color_rgb(199, 209, 222, 0.55f)); + ImPlot::PushStyleColor(ImPlotCol_Selection, cabana_mode ? color_rgb(117, 161, 242, 0.22f) : color_rgb(252, 211, 77, 0.28f)); + ImPlot::PushStyleColor(ImPlotCol_Crosshairs, cabana_mode ? color_rgb(214, 219, 225, 0.70f) : color_rgb(120, 128, 138, 0.70f)); + ImPlot::PushStyleVar(ImPlotStyleVar_LegendPadding, cabana_mode ? ImVec2(10.0f, 10.0f) : 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/app_render_flow.cc b/tools/jotpluggler/app_render_flow.cc new file mode 100644 index 00000000000000..6b7e5cefafad7e --- /dev/null +++ b/tools/jotpluggler/app_render_flow.cc @@ -0,0 +1,197 @@ +#include "tools/jotpluggler/app_internal.h" + +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" + +#include + +#include +#include + +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; + char label[32] = {}; + std::snprintf(label, sizeof(label), "%.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); + 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, nullptr); +} + +void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { + if (!state->fps_overlay_initialized) { + static const bool kDefaultShowFpsOverlay = env_flag_enabled("JOTP_SHOW_FPS"); + state->show_fps_overlay = kDefaultShowFpsOverlay; + state->fps_overlay_initialized = true; + } + if (!state->view_mode_initialized) { + static const bool kDefaultCabanaMode = env_flag_enabled("JOTP_START_CABANA"); + state->view_mode = (state->start_cabana || kDefaultCabanaMode) ? AppViewMode::Cabana : AppViewMode::Plot; + state->view_mode_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); + const bool cabana_mode = state->view_mode == AppViewMode::Cabana; + UiMetrics ui = compute_ui_metrics(ImGui::GetMainViewport()->Size, menu_height, + cabana_mode ? 0.0f : state->sidebar_width); + if (cabana_mode) { + ui.content_h += STATUS_BAR_HEIGHT; + ui.status_bar_y += STATUS_BAR_HEIGHT; + } + if (!cabana_mode) { + 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); + } + } else { + state->custom_series.selected = false; + state->logs.selected = false; + draw_cabana_mode(session, ui, state); + } + if (!cabana_mode) { + 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::ofstream out(ppm_path, std::ios::binary); + if (!out) throw std::runtime_error("Failed to open " + ppm_path.string()); + out << "P6\n" << width << " " << height << "\n255\n"; + 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); + out.write(reinterpret_cast(&pixels[index]), 3); + } + } + } + + const std::string command = "convert " + shell_quote(ppm_path.string()) + " " + shell_quote(output_path.string()); + run_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/app_runtime.cc b/tools/jotpluggler/app_runtime.cc new file mode 100644 index 00000000000000..bcae4d608c17ab --- /dev/null +++ b/tools/jotpluggler/app_runtime.cc @@ -0,0 +1,1418 @@ +#include "tools/jotpluggler/jotpluggler.h" +#include "tools/jotpluggler/app_common.h" +#include "tools/jotpluggler/app_socketcan.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/cabana/panda.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}; + +bool camera_timing_logging_enabled() { + static const bool enabled = env_flag_enabled("JOTP_CAMERA_TIMINGS"); + return enabled; +} + +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; + } +} + +std::string normalize_stream_address(std::string address) { + return is_local_stream_address(address) ? "127.0.0.1" : address; +} + +std::string stream_source_target_label(const StreamSourceConfig &source) { + switch (source.kind) { + case StreamSourceKind::CerealRemote: + return normalize_stream_address(source.address); + case StreamSourceKind::Panda: + return source.panda.serial.empty() ? std::string("auto") : source.panda.serial; + case StreamSourceKind::SocketCan: + return source.socketcan.device.empty() ? std::string("can0") : source.socketcan.device; + case StreamSourceKind::CerealLocal: + default: + return "127.0.0.1"; + } +} + +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); + } else if (source.kind == StreamSourceKind::SocketCan && source.socketcan.device.empty()) { + source.socketcan.device = "can0"; + } + 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; + case StreamSourceKind::Panda: + run_panda_source(&accumulator); + break; + case StreamSourceKind::SocketCan: + run_socketcan_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); + } + } + + void configure_panda(Panda *panda) const { + panda->set_safety_model(cereal::CarParams::SafetyModel::NO_OUTPUT); + for (size_t bus = 0; bus < source.panda.buses.size(); ++bus) { + const PandaBusConfig &cfg = source.panda.buses[bus]; + panda->set_can_speed_kbps(static_cast(bus), static_cast(cfg.can_speed_kbps)); + if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA + || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) { + panda->set_data_speed_kbps(static_cast(bus), + static_cast(cfg.can_fd ? cfg.data_speed_kbps : 10)); + } + } + } + + void run_panda_source(StreamAccumulator *accumulator) { + std::unique_ptr panda; + std::vector raw_can_data; + while (running.load()) { + if (!panda || !panda->connected()) { + connected.store(false); + try { + panda = std::make_unique(source.panda.serial); + configure_panda(panda.get()); + clear_error_text(); + connected.store(true); + } catch (const std::exception &err) { + set_error_text(err.what()); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + continue; + } + } + + raw_can_data.clear(); + if (!panda->can_receive(raw_can_data)) { + connected.store(false); + panda.reset(); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + continue; + } + if (raw_can_data.empty()) { + panda->send_heartbeat(false); + publish_batch(accumulator); + continue; + } + + received_messages.fetch_add(raw_can_data.size()); + if (!paused.load()) { + const double mono_time = static_cast(nanos_since_boot()) / 1.0e9; + std::vector frames; + frames.reserve(raw_can_data.size()); + for (const can_frame &frame : raw_can_data) { + frames.push_back(LiveCanFrame{ + .mono_time = mono_time, + .bus = static_cast(frame.src), + .address = static_cast(frame.address), + .bus_time = 0, + .data = frame.dat, + }); + } + accumulator->appendCanFrames(CanServiceKind::Can, frames); + } + panda->send_heartbeat(false); + publish_batch(accumulator); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + void run_socketcan_source(StreamAccumulator *accumulator) { +#ifdef __linux__ + while (running.load()) { + connected.store(false); + try { + SocketCanReader reader(source.socketcan.device.empty() ? "can0" : source.socketcan.device); + clear_error_text(); + connected.store(true); + while (running.load()) { + LiveCanFrame frame; + if (!reader.readFrame(&frame)) { + publish_batch(accumulator); + continue; + } + received_messages.fetch_add(1); + if (!paused.load()) { + std::vector frames; + frames.push_back(std::move(frame)); + accumulator->appendCanFrames(CanServiceKind::Can, frames); + } + publish_batch(accumulator); + } + } catch (const std::exception &err) { + set_error_text(err.what()); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + } +#else + (void)accumulator; + throw std::runtime_error("SocketCAN is not available on this platform"); +#endif + } + + 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(); + } + if (result.success && result.decode_ms > 0.0 && camera_timing_logging_enabled()) { + std::fprintf(stderr, "camera[%s] seg=%d idx=%d %.1fms\n", + camera_view_spec(camera_view).runtime_name, + result.key.segment, + result.key.decode_index, + result.decode_ms); + } + } + + 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 (camera_timing_logging_enabled()) { + 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/app_session_flow.cc b/tools/jotpluggler/app_session_flow.cc new file mode 100644 index 00000000000000..2fac1ae5940853 --- /dev/null +++ b/tools/jotpluggler/app_session_flow.cc @@ -0,0 +1,798 @@ +#include "tools/jotpluggler/app_internal.h" + +#include "imgui_internal.h" + +#include +#include +#include +#include + +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) { + const auto &views = camera_view_specs(); + for (size_t i = 0; i < views.size(); ++i) { + if (session->pane_camera_feeds[i]) { + session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(views[i].route_member), views[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); + if (state->view_mode == AppViewMode::Cabana) { + state->browser_nodes_dirty = true; + } else { + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } + rebuild_cabana_messages(session); + 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); +} + +std::string layout_combo_label(const AppSession &session, const UiState &state) { + const std::string base = session.layout_path.empty() ? std::string("untitled") : session.layout_path.stem().string(); + return state.layout_dirty ? base + " *" : base; +} + +const char *log_selector_name(LogSelector selector) { + switch (selector) { + case LogSelector::RLog: return "r"; + case LogSelector::QLog: return "q"; + case LogSelector::Auto: + default: return "a"; + } +} + +const char *log_selector_description(LogSelector selector) { + switch (selector) { + case LogSelector::RLog: return "rlog only"; + case LogSelector::QLog: return "qlog only"; + case LogSelector::Auto: + default: return "any of rlog or qlog"; + } +} + +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 = trim_copy(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; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%d:%02d.%03d", minutes, secs, millis); + return buf; +} + +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 = trim_copy(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) { + char buf[64]; + if (bytes >= (1ULL << 30)) { + std::snprintf(buf, sizeof(buf), "%.1f GiB", static_cast(bytes) / static_cast(1ULL << 30)); + } else if (bytes >= (1ULL << 20)) { + std::snprintf(buf, sizeof(buf), "%.1f MiB", static_cast(bytes) / static_cast(1ULL << 20)); + } else if (bytes >= (1ULL << 10)) { + std::snprintf(buf, sizeof(buf), "%.1f KiB", static_cast(bytes) / static_cast(1ULL << 10)); + } else { + std::snprintf(buf, sizeof(buf), "%llu B", static_cast(bytes)); + } + return std::string(buf); +} + +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(); + } + if (ImGui::BeginMenu("View")) { + const bool cabana_mode = state->view_mode == AppViewMode::Cabana; + if (ImGui::MenuItem("Cabana Mode", nullptr, cabana_mode)) { + state->view_mode = cabana_mode ? AppViewMode::Plot : AppViewMode::Cabana; + state->status_text = cabana_mode ? "Plot mode enabled" : "Cabana mode enabled"; + } + 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/app_sidebar_flow.cc b/tools/jotpluggler/app_sidebar_flow.cc new file mode 100644 index 00000000000000..ae9e26d1e889d9 --- /dev/null +++ b/tools/jotpluggler/app_sidebar_flow.cc @@ -0,0 +1,215 @@ +#include "tools/jotpluggler/app_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/app_socketcan.cc b/tools/jotpluggler/app_socketcan.cc new file mode 100644 index 00000000000000..0c9c3621565990 --- /dev/null +++ b/tools/jotpluggler/app_socketcan.cc @@ -0,0 +1,105 @@ +#include "tools/jotpluggler/app_socketcan.h" + +#include "common/timing.h" + +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#endif + +std::vector list_socketcan_devices() { +#ifdef __linux__ + std::vector devices; + const std::filesystem::path net_dir("/sys/class/net"); + std::error_code ec; + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(net_dir, ec)) { + if (ec) break; + if (!entry.is_directory()) continue; + std::ifstream type_file(entry.path() / "type"); + int type = -1; + if (!(type_file >> type) || type != 280) continue; + devices.push_back(entry.path().filename().string()); + } + std::sort(devices.begin(), devices.end()); + return devices; +#else + return {}; +#endif +} + +SocketCanReader::SocketCanReader(const std::string &device) { +#ifdef __linux__ + sock_fd_ = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (sock_fd_ < 0) { + throw std::runtime_error("Failed to create CAN socket"); + } + + int fd_enable = 1; + setsockopt(sock_fd_, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable)); + + struct ifreq ifr = {}; + std::snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", device.c_str()); + if (ioctl(sock_fd_, SIOCGIFINDEX, &ifr) < 0) { + ::close(sock_fd_); + sock_fd_ = -1; + throw std::runtime_error("Failed to get interface index for " + device); + } + + struct sockaddr_can addr = {}; + addr.can_family = AF_CAN; + addr.can_ifindex = ifr.ifr_ifindex; + if (bind(sock_fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { + ::close(sock_fd_); + sock_fd_ = -1; + throw std::runtime_error("Failed to bind CAN socket"); + } + + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; + setsockopt(sock_fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#else + (void)device; + throw std::runtime_error("SocketCAN is not available on this platform"); +#endif +} + +SocketCanReader::~SocketCanReader() { +#ifdef __linux__ + if (sock_fd_ >= 0) { + ::close(sock_fd_); + sock_fd_ = -1; + } +#endif +} + +bool SocketCanReader::readFrame(LiveCanFrame *frame) { +#ifdef __linux__ + if (frame == nullptr || sock_fd_ < 0) { + return false; + } + struct canfd_frame can_frame = {}; + const ssize_t nbytes = ::read(sock_fd_, &can_frame, sizeof(can_frame)); + if (nbytes <= 0) { + return false; + } + *frame = LiveCanFrame{ + .mono_time = static_cast(nanos_since_boot()) / 1.0e9, + .bus = 0, + .address = can_frame.can_id & CAN_EFF_MASK, + .bus_time = 0, + .data = std::string(reinterpret_cast(can_frame.data), can_frame.len), + }; + return true; +#else + (void)frame; + return false; +#endif +} diff --git a/tools/jotpluggler/app_socketcan.h b/tools/jotpluggler/app_socketcan.h new file mode 100644 index 00000000000000..0de5a85d14a224 --- /dev/null +++ b/tools/jotpluggler/app_socketcan.h @@ -0,0 +1,22 @@ +#pragma once + +#include "tools/jotpluggler/jotpluggler.h" + +#include +#include + +std::vector list_socketcan_devices(); + +class SocketCanReader { +public: + explicit SocketCanReader(const std::string &device); + ~SocketCanReader(); + + SocketCanReader(const SocketCanReader &) = delete; + SocketCanReader &operator=(const SocketCanReader &) = delete; + + bool readFrame(LiveCanFrame *frame); + +private: + int sock_fd_ = -1; +}; diff --git a/tools/jotpluggler/app_stream_flow.cc b/tools/jotpluggler/app_stream_flow.cc new file mode 100644 index 00000000000000..9a093ffa644528 --- /dev/null +++ b/tools/jotpluggler/app_stream_flow.cc @@ -0,0 +1,217 @@ +#include "tools/jotpluggler/app_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)); + } + + bool can_messages_changed = false; + 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())); + } + can_messages_changed = true; + } + + 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); + if (state->view_mode == AppViewMode::Cabana) { + state->browser_nodes_dirty = true; + } else { + 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); + } + } + if (new_paths || can_messages_changed) { + rebuild_cabana_messages(session); + } + + 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/bootstrap_icons.cc b/tools/jotpluggler/bootstrap_icons.cc new file mode 100644 index 00000000000000..c19168415c2bfa --- /dev/null +++ b/tools/jotpluggler/bootstrap_icons.cc @@ -0,0 +1,51 @@ +#include "tools/jotpluggler/jotpluggler.h" + +#include + +namespace { + +ImFont *g_icon_font = nullptr; + +const std::filesystem::path &font_path() { + static const std::filesystem::path path = []() -> std::filesystem::path { +#ifdef JOTP_REPO_ROOT + return std::filesystem::path(JOTP_REPO_ROOT) / "third_party" / "bootstrap" / "bootstrap-icons.ttf"; +#else + return std::filesystem::current_path() / "third_party" / "bootstrap" / "bootstrap-icons.ttf"; +#endif + }(); + return path; +} + +} // namespace + +void icon_add_font(float size, bool merge) { + const auto &ttf = font_path(); + ImGuiIO &io = ImGui::GetIO(); + ImFontConfig config; + config.MergeMode = merge; + config.GlyphMinAdvanceX = size; + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImFont *font = io.Fonts->AddFontFromFileTTF(ttf.c_str(), size, &config, ranges); + if (!merge && font != nullptr) { + g_icon_font = font; + } +} + +ImFont *icon_font() { + return g_icon_font; +} + +bool icon_menu_item(const char *glyph, + const char *label, + const char *shortcut, + bool selected, + bool enabled) { + char buf[256]; + if (glyph != nullptr && glyph[0] != '\0') { + std::snprintf(buf, sizeof(buf), "%s %s", glyph, label); + } else { + std::snprintf(buf, sizeof(buf), " %s", label); + } + return ImGui::MenuItem(buf, shortcut, selected, enabled); +} diff --git a/tools/jotpluggler/convert_layouts.py b/tools/jotpluggler/convert_layouts.py new file mode 100755 index 00000000000000..e48e0f37f3a60f --- /dev/null +++ b/tools/jotpluggler/convert_layouts.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +PJ_LAYOUTS = ROOT / "tools" / "plotjuggler" / "layouts" +JOT_LAYOUTS = ROOT / "tools" / "jotpluggler" / "layouts" + + +def indent(text: str, level: int = 1) -> str: + prefix = " " * level + return "\n".join(prefix + line if line else line for line in text.splitlines()) + + +def convert_expr(expr: str) -> str: + out = expr + out = re.sub(r"--(.*)$", r"#\1", out) + out = out.replace("^", "**") + out = out.replace("~=", "!=") + out = out.replace("math.pi", "np.pi") + out = out.replace("math.sin", "np.sin") + out = out.replace("math.cos", "np.cos") + out = out.replace("math.sqrt", "np.sqrt") + out = out.replace("math.abs", "np.abs") + out = out.replace("math.atan", "np.arctan2") + return out + + +def translate_lua_globals(lua_globals: str) -> tuple[str, list[str]]: + translated_lines: list[str] = [] + assigned_names: list[str] = [] + for raw_line in lua_globals.splitlines(): + line = convert_expr(raw_line.rstrip()) + stripped = line.strip() + if not stripped: + continue + match = re.match(r"^([A-Za-z_]\w*)\s*=", stripped) + if match: + assigned_names.append(match.group(1)) + translated_lines.append(stripped) + return "\n".join(translated_lines), assigned_names + + +def translate_lua_function(lua_function: str, global_names: list[str], additional_count: int) -> str: + translated: list[str] = [] + if global_names: + translated.append("global " + ", ".join(global_names)) + + indent_level = 0 + for raw_line in lua_function.splitlines(): + line = convert_expr(raw_line.rstrip()) + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + translated.append(" " * indent_level + stripped) + continue + if stripped == "end": + indent_level = max(0, indent_level - 1) + continue + if stripped == "else": + indent_level = max(0, indent_level - 1) + translated.append(" " * indent_level + "else:") + indent_level += 1 + continue + if stripped.startswith("elseif ") and stripped.endswith(" then"): + indent_level = max(0, indent_level - 1) + condition = stripped[len("elseif "):-len(" then")].strip() + translated.append(" " * indent_level + f"elif {condition}:") + indent_level += 1 + continue + if stripped.startswith("if ") and stripped.endswith(" then"): + condition = stripped[len("if "):-len(" then")].strip() + translated.append(" " * indent_level + f"if {condition}:") + indent_level += 1 + continue + translated.append(" " * indent_level + stripped) + + args = ["time", "value"] + [f"v{i}" for i in range(1, additional_count + 1)] + lines = [ + f"def __jotpluggler_eval_sample({', '.join(args)}):", + indent("\n".join(translated) if translated else "return value"), + "", + "__jotpluggler_result = np.empty_like(value, dtype=np.float64)", + "for __jotpluggler_i in range(len(value)):", + ] + call_args = ["time[__jotpluggler_i]", "value[__jotpluggler_i]"] + for i in range(1, additional_count + 1): + call_args.append(f"v{i}[__jotpluggler_i]") + lines.append(indent(f"__jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample({', '.join(call_args)})")) + lines.append("return __jotpluggler_result") + return "\n".join(lines) + + +def parse_snippets(root: ET.Element) -> dict[str, dict]: + snippets: dict[str, dict] = {} + custom_math = root.find("customMathEquations") + if custom_math is None: + return snippets + + for snippet in custom_math.findall("snippet"): + name = snippet.get("name", "").strip() + if not name: + continue + linked_source = (snippet.findtext("linked_source") or "").strip() + additional_sources: list[str] = [] + additional = snippet.find("additional_sources") + if additional is not None: + for child in additional: + if child.text and child.text.strip(): + additional_sources.append(child.text.strip()) + globals_code, global_names = translate_lua_globals((snippet.findtext("global") or "").strip()) + function_code = translate_lua_function((snippet.findtext("function") or "").strip(), global_names, len(additional_sources)) + snippets[name] = { + "linked_source": linked_source, + "additional_sources": additional_sources, + "globals_code": globals_code, + "function_code": function_code, + } + return snippets + + +def parse_transform(curve_elem: ET.Element) -> dict: + transform = curve_elem.find("transform") + if transform is None: + return {} + name = transform.get("name", "") + options = transform.find("options") + if name == "Derivative": + result = {"transform": "derivative"} + if options is not None: + dt = options.get("dTime") + if dt is None and options.get("radioChecked") == "radioCustom": + dt = options.get("lineEdit") + if dt is not None: + result["derivative_dt"] = float(dt) + return result + if name == "Scale/Offset" and options is not None: + return { + "transform": "scale", + "scale": float(options.get("value_scale", "1")), + "offset": float(options.get("value_offset", "0")), + } + return {} + + +def convert_curve(curve_elem: ET.Element, snippets: dict[str, dict]) -> dict: + name = curve_elem.get("name", "") + curve = { + "name": name, + "color": curve_elem.get("color", "#a0aab4"), + } + curve.update(parse_transform(curve_elem)) + if name in snippets: + curve["custom_python"] = snippets[name] + return curve + + +def parse_range(plot_elem: ET.Element) -> dict: + range_elem = plot_elem.find("range") + if range_elem is None: + return {} + return { + "left": float(range_elem.get("left", "0")), + "right": float(range_elem.get("right", "1")), + "top": float(range_elem.get("top", "1")), + "bottom": float(range_elem.get("bottom", "0")), + } + + +def parse_y_limits(plot_elem: ET.Element) -> dict | None: + limit_elem = plot_elem.find("limitY") + if limit_elem is None: + return None + limits = {} + if "min" in limit_elem.attrib: + limits["min"] = float(limit_elem.get("min", "0")) + if "max" in limit_elem.attrib: + limits["max"] = float(limit_elem.get("max", "0")) + return limits or None + + +def convert_dock_area(area_elem: ET.Element, snippets: dict[str, dict]) -> dict: + plot_elem = area_elem.find("plot") + if plot_elem is None: + raise ValueError("DockArea missing plot") + pane = { + "title": area_elem.get("name", "..."), + "range": parse_range(plot_elem), + "curves": [convert_curve(curve, snippets) for curve in plot_elem.findall("curve")], + } + y_limits = parse_y_limits(plot_elem) + if y_limits is not None: + pane["y_limits"] = y_limits + return pane + + +def convert_node(elem: ET.Element, snippets: dict[str, dict]) -> dict: + if elem.tag == "DockArea": + return convert_dock_area(elem, snippets) + if elem.tag != "DockSplitter": + raise ValueError(f"Unsupported layout node {elem.tag}") + orientation = elem.get("orientation", "-") + split = "vertical" if orientation == "-" else "horizontal" + children = [convert_node(child, snippets) for child in elem if child.tag in {"DockArea", "DockSplitter"}] + sizes_raw = elem.get("sizes", "") + sizes = [float(part) for part in sizes_raw.split(";") if part] + return { + "split": split, + "sizes": sizes if len(sizes) == len(children) else [1.0 / len(children)] * len(children), + "children": children, + } + + +def convert_xml_layout(path: Path) -> dict: + root = ET.parse(path).getroot() + snippets = parse_snippets(root) + tabs = [] + tabbed = root.find("tabbed_widget") + if tabbed is None: + raise ValueError("Missing tabbed_widget") + for tab_elem in tabbed.findall("Tab"): + container = tab_elem.find("Container") + if container is None: + continue + content = next((child for child in container if child.tag in {"DockArea", "DockSplitter"}), None) + if content is None: + continue + tabs.append({ + "name": tab_elem.get("tab_name", "tab1"), + "root": convert_node(content, snippets), + }) + current_index = 0 + current = tabbed.find("currentTabIndex") + if current is None: + current = root.find(".//currentTabIndex") + if current is not None: + current_index = int(current.get("index", "0")) + return { + "current_tab_index": current_index, + "tabs": tabs, + } + + +def convert_all(layout_dir: Path, out_dir: Path) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + for xml_path in sorted(layout_dir.glob("*.xml")): + converted = convert_xml_layout(xml_path) + out_path = out_dir / f"{xml_path.stem}.json" + out_path.write_text(json.dumps(converted, separators=(",", ":")) + "\n", encoding="utf-8") + print(out_path.relative_to(ROOT)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--src", type=Path, default=PJ_LAYOUTS) + parser.add_argument("--out", type=Path, default=JOT_LAYOUTS) + args = parser.parse_args() + convert_all(args.src, args.out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/jotpluggler/dbc_core.cc b/tools/jotpluggler/dbc_core.cc new file mode 100644 index 00000000000000..62d2ff3d29fa2c --- /dev/null +++ b/tools/jotpluggler/dbc_core.cc @@ -0,0 +1,328 @@ +#include "tools/jotpluggler/dbc_core.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dbc { + +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; +} + +std::string trim_copy(std::string_view text) { + size_t start = 0; + size_t end = text.size(); + while (start < end && std::isspace(static_cast(text[start]))) ++start; + while (end > start && std::isspace(static_cast(text[end - 1]))) --end; + return std::string(text.substr(start, end - start)); +} + +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 = trim_copy(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 + +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; + } +} + +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]] 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()); +} + +Database::Database(const std::filesystem::path &path) { + std::ifstream in(path); + if (!in) throw std::runtime_error("Failed to open DBC " + path.string()); + std::ostringstream buffer; + buffer << in.rdbuf(); + parse(buffer.str(), path.filename().string()); +} + +Database Database::fromContent(const std::string &content, const std::string &filename) { + Database db; + db.parse(content, filename); + return db; +} + +const Message *Database::message(uint32_t address) const { + auto it = messages_.find(address); + return it == messages_.end() ? nullptr : &it->second; +} + +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; +} + +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 = trim_copy(raw_line); + if (line.empty()) continue; + if (line.rfind("BO_ ", 0) == 0) { + parseBo(line, line_number, ¤t_message); + } else if (line.rfind("SG_ ", 0) == 0) { + if (current_message == nullptr) { + parse_error(filename, line_number, "Signal without current message", line); + } + parseSg(line, line_number, current_message); + } else if (line.rfind("VAL_ ", 0) == 0) { + parseVal(line, line_number); + } else if (line.rfind("CM_ BO_", 0) == 0) { + parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number); + } else if (line.rfind("CM_ SG_", 0) == 0) { + parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number); + } + } + finalize(); +} + +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; +} + +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 = trim_copy(match[11 + offset].str()); + updateMsbLsb(&signal); + current_message->signals.push_back(std::move(signal)); +} + +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(), + }); + } +} + +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()); + } +} + +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()); + } +} + +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; + } + } + } +} + +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/dbc_core.h b/tools/jotpluggler/dbc_core.h new file mode 100644 index 00000000000000..dbbb94051d1c6f --- /dev/null +++ b/tools/jotpluggler/dbc_core.h @@ -0,0 +1,84 @@ +#pragma once + +#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 dbc diff --git a/tools/jotpluggler/jotpluggler.h b/tools/jotpluggler/jotpluggler.h new file mode 100644 index 00000000000000..6f7b7fe463a001 --- /dev/null +++ b/tools/jotpluggler/jotpluggler.h @@ -0,0 +1,1169 @@ +#pragma once + +#include "cereal/gen/cpp/log.capnp.h" +#include "imgui.h" +#include "tools/jotpluggler/dbc_core.h" + +#include +#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; + bool start_cabana = 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); +ImFont *icon_font(); +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, + Panda, + SocketCan, +}; + +struct PandaBusConfig { + int can_speed_kbps = 500; + int data_speed_kbps = 2000; + bool can_fd = false; +}; + +struct PandaStreamConfig { + std::string serial; + std::array buses = {}; +}; + +struct SocketCanStreamConfig { + std::string device; +}; + +struct StreamSourceConfig { + StreamSourceKind kind = StreamSourceKind::CerealLocal; + std::string address = "127.0.0.1"; + PandaStreamConfig panda; + SocketCanStreamConfig socketcan; +}; + +enum class AppViewMode : uint8_t { + Plot, + Cabana, +}; + +struct BrowserNode { + std::string label; + std::string full_path; + std::vector children; +}; + +struct CabanaSignalSummary { + std::string path; + std::string name; + std::string unit; + std::string receiver_name; + std::string comment; + 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; + int type = 0; + int multiplex_value = 0; + int value_description_count = 0; + bool is_signed = false; + bool is_little_endian = false; + bool has_bit_range = false; +}; + +struct CabanaMessageSummary { + std::string root_path; + std::string service; + std::string name; + std::string node; + std::vector signals; + int bus = -1; + uint32_t address = 0; + int dbc_size = -1; + bool has_address = false; + size_t sample_count = 0; + double frequency_hz = 0.0; +}; + +struct CabanaSimilarBitMatch { + std::string message_root; + std::string label; + int bus = -1; + uint32_t address = 0; + int byte_index = -1; + int bit_index = -1; + double score = 0.0; + double ones_ratio = 0.0; + double flip_ratio = 0.0; +}; + +struct CabanaChartState { + int id = 0; + int series_type = 0; + std::vector signal_paths; + std::vector hidden; +}; + +struct CabanaChartTabState { + int id = 0; + std::vector charts; +}; + +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::vector cabana_messages; + 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 CabanaUiState { + float layout_left_frac = 0.30f; + float layout_center_frac = 0.32f; + float layout_center_top_frac = 0.58f; + float layout_signal_list_frac = 0.56f; + float layout_right_top_frac = 0.52f; + bool detail_top_auto_fit = true; + std::array message_filter = {}; + std::array message_bus_filter = {}; + std::array message_addr_filter = {}; + std::array message_node_filter = {}; + std::array message_freq_filter = {}; + std::array message_count_filter = {}; + std::array message_bytes_filter = {}; + std::array signal_filter = {}; + int sparkline_range_sec = 15; + bool suppress_defined_signals = false; + bool sync_message_tabs = true; + std::string selected_message_root; + std::string selected_signal_path; + std::vector open_message_roots; + std::vector chart_signal_paths; + int detail_tab = 0; + CameraViewKind camera_view = CameraViewKind::Road; + bool heatmap_live_mode = true; + bool logs_hex_mode = true; + int logs_filter_compare = 0; + std::array logs_filter_value = {}; + bool has_bit_selection = false; + int selected_bit_byte = -1; + int selected_bit_index = -1; + bool binary_drag_active = false; + bool binary_drag_resizing = false; + bool binary_drag_moved = false; + bool binary_drag_signal_is_little_endian = true; + bool pending_apply_signal_edit = false; + bool pending_delete_signal = false; + int binary_drag_press_byte = -1; + int binary_drag_press_bit = -1; + int binary_drag_anchor_byte = -1; + int binary_drag_anchor_bit = -1; + int binary_drag_current_byte = -1; + int binary_drag_current_bit = -1; + std::string binary_drag_signal_path; + std::string similar_bits_source_root; + int similar_bits_source_byte = -1; + int similar_bits_source_bit = -1; + bool similar_bits_loading = false; + std::vector similar_bit_matches; + std::future> similar_bit_future; + std::vector chart_tabs; + std::vector>> chart_zoom_history; + std::vector>> chart_zoom_redo; + int next_chart_tab_id = 1; + int next_chart_id = 1; + int active_chart_tab = 0; + int active_chart_index = 0; + int chart_columns = 1; + bool chart_scrub_was_playing = false; + bool chart_zoom_drag_active = false; + int chart_zoom_drag_chart_id = -1; + float chart_zoom_drag_plot_min_x = 0.0f; + float chart_zoom_drag_plot_min_y = 0.0f; + float chart_zoom_drag_plot_max_x = 0.0f; + float chart_zoom_drag_plot_max_y = 0.0f; + float chart_zoom_drag_start_x = 0.0f; + bool chart_timeline_zoom_drag_active = false; + float chart_timeline_zoom_start_x = 0.0f; + float chart_timeline_zoom_min_x = 0.0f; + float chart_timeline_zoom_max_x = 0.0f; + double chart_timeline_zoom_range_min = 0.0; + double chart_timeline_zoom_range_max = 0.0; + double chart_hover_sec = -1.0; +}; + +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::string source_path; + std::string save_name; + std::string text; +}; + +struct CabanaSignalEditorState { + bool open = false; + bool loaded = false; + bool creating = false; + std::string message_root; + std::string message_name; + std::string service; + std::string signal_path; + int bus = -1; + uint32_t message_address = 0; + std::string original_signal_name; + std::string signal_name; + int start_bit = 0; + int size = 1; + double factor = 1.0; + double offset = 0.0; + double min = 0.0; + double max = 0.0; + bool is_signed = false; + bool is_little_endian = true; + int type = 0; + int multiplex_value = 0; + std::string receiver_name; + std::string unit; +}; + +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 cabana_mode_initialized = 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 view_mode_initialized = false; + bool start_cabana = 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; + AppViewMode view_mode = AppViewMode::Plot; + bool focus_rename_tab_input = false; + std::vector tabs; + std::array route_buffer = {}; + std::array stream_address_buffer = {}; + std::array panda_serial_buffer = {}; + std::array socketcan_device_buffer = {}; + std::array rename_tab_buffer = {}; + std::array browser_filter = {}; + std::array data_dir_buffer = {}; + std::array load_layout_buffer = {}; + std::array save_layout_buffer = {}; + std::array 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; + std::array panda_can_speed_kbps = {500, 500, 500}; + std::array panda_data_speed_kbps = {2000, 2000, 2000}; + std::array panda_can_fd = {false, false, false}; + 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; + CabanaSignalEditorState cabana_signal_editor; + CabanaUiState cabana; + CustomSeriesEditorState custom_series; + LogsUiState logs; + UndoStack undo; +}; + +// inline helpers + +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 trim_copy(std::string_view text) { + size_t begin = 0; + size_t end = text.size(); + while (begin < end && std::isspace(static_cast(text[begin]))) { + ++begin; + } + while (end > begin && std::isspace(static_cast(text[end - 1]))) { + --end; + } + return std::string(text.substr(begin, end - begin)); +} + +inline std::string lowercase(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 (const char c : value) { + if (c == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(c); + } + } + quoted.push_back('\''); + return quoted; +} + +// 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/main.cc b/tools/jotpluggler/main.cc index 22bc29664c2616..7966f8b645c774 100644 --- a/tools/jotpluggler/main.cc +++ b/tools/jotpluggler/main.cc @@ -1,7 +1,7 @@ #include #include -#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/jotpluggler.h" namespace { @@ -22,11 +22,13 @@ void print_usage(const char *argv0) { << " --output \n" << " --show\n" << " --sync-load\n" + << " --cabana\n" << "\n" << "Examples:\n" << " " << argv0 << "\n" << " " << argv0 << " --demo\n" << " " << argv0 << " --layout longitudinal --demo\n" + << " " << argv0 << " --layout longitudinal --demo --cabana\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"; @@ -94,6 +96,8 @@ int main(int argc, char *argv[]) { options.show = true; } else if (arg == "--sync-load") { options.sync_load = true; + } else if (arg == "--cabana") { + options.start_cabana = true; } else if (arg == "--help" || arg == "-h") { print_usage(argv[0]); return 0; diff --git a/tools/jotpluggler/materialize_generated_dbcs.py b/tools/jotpluggler/materialize_generated_dbcs.py new file mode 100755 index 00000000000000..9b2109ed58926f --- /dev/null +++ b/tools/jotpluggler/materialize_generated_dbcs.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from opendbc import get_generated_dbcs + + +def main() -> int: + parser = argparse.ArgumentParser(description="Materialize generated opendbc DBCs for JotPlugger") + parser.add_argument("--out", required=True) + args = parser.parse_args() + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + for existing in out_dir.glob("*.dbc"): + existing.unlink() + + for name, content in sorted(get_generated_dbcs().items()): + (out_dir / f"{name}.dbc").write_text(content) + + (out_dir / ".stamp").write_text("ok\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc index fd15e3d91642cf..c03672da1dd6d4 100644 --- a/tools/jotpluggler/sketch_layout.cc +++ b/tools/jotpluggler/sketch_layout.cc @@ -1,6 +1,5 @@ -#include "tools/jotpluggler/app.h" -#include "tools/jotpluggler/car_fingerprint_to_dbc.h" -#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/jotpluggler.h" +#include "tools/jotpluggler/app_common.h" #include #include @@ -206,15 +205,14 @@ struct LoadStats { mutable std::mutex progress_mutex; }; -// Skip individual messages that our local Cap'n Proto schema can't project, -// such as logs recorded by a newer build. +// Skip individual messages our local schema can't project, which can happen +// when loading logs from a newer build. template void with_parseable_event(kj::ArrayPtr data, Fn &&fn) { try { capnp::FlatArrayMessageReader event_reader(data); fn(event_reader.getRoot()); } catch (const kj::Exception &) { - return; } } @@ -258,7 +256,7 @@ const std::string &selected_log_path(const SegmentLogs &segment, LogSelector sel RouteSelection parse_route_selection(std::string route_name) { RouteSelection route = {}; - route_name = util::strip(route_name); + route_name = trim_copy(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()))); @@ -397,15 +395,50 @@ RouteIdentifier make_route_identifier(const RouteSelection &route, const std::ma return route_id; } +std::string basedir() { + if (const char *env_basedir = std::getenv("BASEDIR"); env_basedir != nullptr && std::strlen(env_basedir) > 0) { + return env_basedir; + } +#ifdef JOTP_REPO_ROOT + return JOTP_REPO_ROOT; +#else + return fs::current_path().string(); +#endif +} + +const std::unordered_map &car_fingerprint_to_dbc_map() { + static const std::unordered_map map = []() { + std::unordered_map out; + const fs::path json_path = fs::path(basedir()) / "tools" / "cabana" / "dbc" / "car_fingerprint_to_dbc.json"; + const std::string raw = util::read_file(json_path.string()); + if (raw.empty()) return out; + std::string parse_error; + const json11::Json parsed = json11::Json::parse(raw, parse_error); + if (!parse_error.empty() || !parsed.is_object()) { + return out; + } + for (const auto &[fingerprint, dbc] : parsed.object_items()) { + if (dbc.is_string() && !dbc.string_value().empty()) { + out.emplace(fingerprint, dbc.string_value()); + } + } + return out; + }(); + return map; +} + std::string detect_dbc_for_fingerprint(std::string_view car_fingerprint) { - return std::string(dbc_for_car_fingerprint(car_fingerprint)); + if (car_fingerprint.empty()) return {}; + const auto &map = car_fingerprint_to_dbc_map(); + auto it = map.find(std::string(car_fingerprint)); + return it == map.end() ? std::string() : it->second; } 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", + fs::path(basedir()) / "opendbc" / "dbc", + fs::path(basedir()) / "tools" / "jotpluggler" / "generated_dbcs", }) { if (fs::exists(dbc_dir) && fs::is_directory(dbc_dir)) { for (const auto &entry : fs::directory_iterator(dbc_dir)) { @@ -416,9 +449,9 @@ std::vector available_dbc_names_impl() { } } } - for (const auto &[_, dbc_name] : kCarFingerprintToDbc) { + for (const auto &[_, dbc_name] : car_fingerprint_to_dbc_map()) { if (!dbc_name.empty()) { - names.insert(std::string(dbc_name)); + names.insert(dbc_name); } } return std::vector(names.begin(), names.end()); @@ -426,8 +459,8 @@ std::vector available_dbc_names_impl() { 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"), + fs::path(basedir()) / "opendbc" / "dbc" / (dbc_name + ".dbc"), + fs::path(basedir()) / "tools" / "jotpluggler" / "generated_dbcs" / (dbc_name + ".dbc"), }) { if (fs::exists(candidate)) return candidate; } @@ -1288,8 +1321,6 @@ void append_event_fast_reader(cereal::Event::Which which, return; } const ResolvedService &service = *schema.by_which[which_index]; - const capnp::DynamicStruct::Reader dynamic_event(event); - const capnp::DynamicValue::Reader payload = dynamic_event.get(service.union_field); const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset; append_fixed_scalar_point(&series->fixed_series[static_cast(service.valid_slot)], tm, @@ -1338,7 +1369,8 @@ void append_event_fast_reader(cereal::Event::Which which, } } - append_fast_node(service.payload, payload, tm, series); + const capnp::DynamicStruct::Reader dynamic_event(event); + append_fast_node(service.payload, dynamic_event.get(service.union_field), tm, series); } void append_event_fast(cereal::Event::Which which, @@ -2032,9 +2064,8 @@ void StreamAccumulator::setDbcName(const std::string &dbc_name) { impl_->refresh_dbc(); } -void StreamAccumulator::appendEvent(kj::ArrayPtr data) { +void StreamAccumulator::appendEvent(cereal::Event::Which which, kj::ArrayPtr data) { with_parseable_event(data, [&](const cereal::Event::Reader &event) { - const cereal::Event::Which which = event.which(); const double boot_time = static_cast(event.getLogMonoTime()) / 1.0e9; if (!impl_->time_offset.has_value()) { impl_->time_offset = boot_time; diff --git a/tools/jotpluggler/test_jotpluggler.py b/tools/jotpluggler/test_jotpluggler.py new file mode 100755 index 00000000000000..a9cc00743c37dd --- /dev/null +++ b/tools/jotpluggler/test_jotpluggler.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +"""Fuzz test for jotpluggler — random UI interactions looking for crashes and freezes. +Each test gets its own Xvfb + process so all 20 run in parallel via xdist.""" +from __future__ import annotations + +import os +import random +import re +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + +import pytest + +BINARY = Path(__file__).resolve().parent / "jotpluggler" +WIDTH, HEIGHT = 1280, 720 +DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496" +FUZZ_ACTIONS = 300 +SCREENSHOT_EVERY = 30 +FREEZE_TIMEOUT_S = 8.0 +ROUNDS_PER_SCENARIO = 10 + +KEYS = [ + "Escape", "Return", "Tab", "space", "Delete", "BackSpace", + "Up", "Down", "Left", "Right", + "ctrl+z", "ctrl+y", "ctrl+s", "ctrl+a", "ctrl+c", "ctrl+v", + "Home", "End", "Page_Up", "Page_Down", "F1", "F5", "F11", "plus", "minus", +] + + +@dataclass(frozen=True) +class Scenario: + name: str + cabana: bool = False + + +SCENARIOS = [ + Scenario("plot"), + Scenario("cabana", cabana=True), +] + + +class Session: + def __init__(self, width: int = WIDTH, height: int = HEIGHT): + self.xvfb = self.jotp = None + self.display = self.winid = "" + self.env: dict[str, str] = {} + self.tmpdir = Path(tempfile.mkdtemp(prefix="jotp_fuzz_")) + self._fhs = [] + self._w, self._h = width, height + + def start(self, *, route: str, cabana: bool = False, layout: str | None = None, sync_load: bool = False): + self.xvfb = subprocess.Popen( + ["Xvfb", "-displayfd", "1", "-screen", "0", f"{self._w}x{self._h}x24", "-dpi", "96", "-nolisten", "tcp"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + self.display = f":{self.xvfb.stdout.readline().strip()}" + assert self.display != ":" + + self.env = os.environ.copy() + self.env["DISPLAY"] = self.display + if cabana: + self.env["JOTP_START_CABANA"] = "1" + + cmd = [str(BINARY), "--show", "--width", str(self._w), "--height", str(self._h)] + if layout: + cmd.extend(["--layout", layout]) + if sync_load: + cmd.append("--sync-load") + cmd.append(route) + + stdout_fh = open(self.tmpdir / "stdout.log", "w") + stderr_fh = open(self.tmpdir / "stderr.log", "w") + self._fhs = [stdout_fh, stderr_fh] + self.jotp = subprocess.Popen(cmd, env=self.env, stdout=stdout_fh, stderr=stderr_fh) + + proc = subprocess.run( + ["xdotool", "search", "--sync", "--name", "jotpluggler"], + env=self.env, capture_output=True, text=True, timeout=30, + ) + wids = proc.stdout.strip().splitlines() + assert wids, "jotpluggler window never appeared" + self.winid = wids[0] + + self._xdo("windowmove", self.winid, "0", "0") + self._xdo("windowsize", "--sync", self.winid, str(self._w), str(self._h)) + self._xdo("windowfocus", "--sync", self.winid) + time.sleep(2.0) + + def stop(self): + for p in (self.jotp, self.xvfb): + if p and p.poll() is None: + p.terminate() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + for fh in self._fhs: + fh.close() + if self.xvfb: + for s in (self.xvfb.stdout, self.xvfb.stderr): + if s: + s.close() + + @property + def alive(self): + return self.jotp is not None and self.jotp.poll() is None + + def crash_reason(self) -> str: + p = self.tmpdir / "stderr.log" + text = p.read_text(errors="replace") if p.exists() else "" + for pat in [r"Assertion.*failed", r"SIGSEGV|SIGABRT|SIGFPE|SIGBUS", r"AddressSanitizer"]: + m = re.search(pat, text) + if m: + start = text.rfind("\n", 0, m.start()) + 1 + end = text.find("\n", m.end()) + return text[start:end if end != -1 else len(text)].strip() + return f"exit code {self.jotp.poll() if self.jotp else '?'}" + + def stderr_tail(self, n: int = 20) -> str: + p = self.tmpdir / "stderr.log" + return "\n".join(p.read_text(errors="replace").splitlines()[-n:]) if p.exists() else "" + + def _xdo(self, *args: str, timeout: float = 5.0): + subprocess.run(["xdotool", *args], env=self.env, check=True, timeout=timeout) + + def click(self, x, y, button=1): + self._xdo("mousemove", str(x), str(y)) + time.sleep(0.02) + self._xdo("click", str(button)) + time.sleep(0.05) + return f"click ({x}, {y}) btn={button}" + + def doubleclick(self, x, y): + self._xdo("mousemove", str(x), str(y)) + time.sleep(0.02) + self._xdo("click", "--repeat", "2", "--delay", "80", "1") + time.sleep(0.05) + return f"dblclick ({x}, {y})" + + def drag(self, x1, y1, x2, y2): + self._xdo("mousemove", str(x1), str(y1)) + time.sleep(0.02) + self._xdo("mousedown", "1") + for i in range(1, 7): + a = i / 6 + self._xdo("mousemove", str(round(x1 + (x2 - x1) * a)), str(round(y1 + (y2 - y1) * a))) + time.sleep(0.02) + self._xdo("mouseup", "1") + time.sleep(0.05) + return f"drag ({x1},{y1})->({x2},{y2})" + + def scroll(self, x, y, clicks): + self._xdo("mousemove", str(x), str(y)) + self._xdo("click", "--repeat", str(abs(clicks)), "4" if clicks > 0 else "5") + time.sleep(0.05) + return f"scroll ({x},{y}) n={clicks}" + + def key(self, keys): + self._xdo("key", keys) + time.sleep(0.05) + return f"key {keys}" + + def type_text(self, text): + self._xdo("type", "--clearmodifiers", text) + time.sleep(0.05) + return f"type {text!r}" + + def mousemove(self, x, y): + self._xdo("mousemove", str(x), str(y)) + return f"move ({x},{y})" + + def screenshot(self, path: Path) -> float: + t0 = time.monotonic() + xwd = Path(tempfile.mktemp(suffix=".xwd")) + try: + subprocess.run(["xwd", "-silent", "-id", self.winid, "-out", str(xwd)], + env=self.env, check=True, timeout=FREEZE_TIMEOUT_S) + subprocess.run(["convert", str(xwd), str(path)], + env=self.env, check=True, timeout=FREEZE_TIMEOUT_S) + finally: + xwd.unlink(missing_ok=True) + return time.monotonic() - t0 + + +def _rand_action(session: Session, rng: random.Random) -> str: + xy = lambda: (rng.randint(0, WIDTH - 1), rng.randint(0, HEIGHT - 1)) + kind = rng.choices( + ["click", "dblclick", "drag", "scroll", "key", "type", "move"], + weights=[35, 8, 15, 15, 15, 5, 7], k=1, + )[0] + if kind == "click": + return session.click(*xy(), rng.choice([1, 1, 1, 1, 3])) + if kind == "dblclick": + return session.doubleclick(*xy()) + if kind == "drag": + x1, y1 = xy() + x2 = max(0, min(WIDTH - 1, x1 + rng.randint(-400, 400))) + y2 = max(0, min(HEIGHT - 1, y1 + rng.randint(-400, 400))) + return session.drag(x1, y1, x2, y2) + if kind == "scroll": + return session.scroll(*xy(), rng.choice([-5, -3, -1, 1, 3, 5])) + if kind == "key": + return session.key(rng.choice(KEYS)) + if kind == "type": + return session.type_text("".join(rng.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=rng.randint(1, 8)))) + return session.mousemove(*xy()) + + +def run_fuzz(scenario: Scenario, round_id: int): + rng = random.Random(round_id * 31 + hash(scenario.name)) + route = DEMO_ROUTE if rng.random() < 0.10 else f"{DEMO_ROUTE}/0/q" + label = f"{scenario.name}-r{round_id:02d}-{'full' if '/' not in route[len(DEMO_ROUTE):] else 'quick'}" + + session = Session() + try: + session.start(route=route, cabana=scenario.cabana) + print(f"\n[{label}] pid={session.jotp.pid} display={session.display} route={'full' if route == DEMO_ROUTE else 'quick'}") + + shots_dir = session.tmpdir / "screenshots" + shots_dir.mkdir() + log: list[str] = [] + crashes, freezes = [], [] + + for i in range(1, FUZZ_ACTIONS + 1): + # periodic screenshot = freeze check + if i % SCREENSHOT_EVERY == 0: + try: + elapsed = session.screenshot(shots_dir / f"{i:04d}.png") + log.append(f"[{i:03d}] screenshot ({elapsed:.2f}s)") + if elapsed > FREEZE_TIMEOUT_S * 0.8: + freezes.append(f"[{i:03d}] slow screenshot ({elapsed:.2f}s)") + except subprocess.TimeoutExpired: + freezes.append(f"[{i:03d}] screenshot timed out ({FREEZE_TIMEOUT_S}s)") + except subprocess.CalledProcessError: + pass + + # random action + try: + desc = _rand_action(session, rng) + except subprocess.TimeoutExpired: + freezes.append(f"[{i:03d}] xdotool timed out") + continue + except subprocess.CalledProcessError: + continue + log.append(f"[{i:03d}] {desc}") + if i <= 5 or i % 50 == 0: + print(f" [{i:03d}/{FUZZ_ACTIONS}] {desc}") + + # crash check + if not session.alive: + reason = session.crash_reason() + crashes.append(f"[{i:03d}] {reason}") + print(f" !! CRASH at action {i}: {reason}") + print(f" !! last: {desc}") + print(f" !! stderr:\n{session.stderr_tail()}") + break + + # write log + log_path = session.tmpdir / "fuzz.log" + log_path.write_text("\n".join(log) + "\n") + print(f" [{label}] done: {len(log)} actions, {len(crashes)} crashes, {len(freezes)} freezes — {log_path}") + + parts = [] + if crashes: + parts.append(f"CRASH: {crashes[0]}") + if freezes: + parts.append(f"FREEZE: {freezes[0]}") + if parts: + pytest.fail(f"[{label}] {' | '.join(parts)} (log: {log_path})") + finally: + session.stop() + + +def _make_fuzz_params(): + return [pytest.param(s, r, id=f"{s.name}-r{r:02d}") + for s in SCENARIOS for r in range(ROUNDS_PER_SCENARIO)] + + +LAYOUTS_DIR = Path(__file__).resolve().parent / "layouts" + +LAYOUT_CHECKS: dict[str, str] = { + "CAN-bus-debug": "panes for CAN bus counters (may use derivative curves that appear flat with limited data)", + "camera-timings": "multiple tabs with camera/model timing curves", + "can-states": "CAN RX/TX/lost/bus-off counters across buses", + "controls_mismatch_debug": "control state, lag, safety checks, or pedal state curves", + "gps": "GPS-related panes (some may be empty if route lacks GPS data)", + "gps_vs_llk": "custom python distance/speed curves (may show error dialog if GPS data is missing — that is acceptable)", + "locationd_debug": "IMU sensor panes (some may be empty if route lacks sensor data — that is acceptable)", + "longitudinal": "4 panes: accel actual vs planned, PID terms, speed, longActive", + "max-torque-debug": "lateral accel or steering torque curves with custom python series", + "system_lag_debug": "CPU usage, CPU temps, or lag curves", + "thermal_debug": "thermal monitoring panes (some may be empty on non-TICI data — that is acceptable)", + "torque-controller": "lateral control curves across multiple tabs", + "tuning": "lateral/longitudinal tuning curves (may show custom series error dialog — that is acceptable)", + "ublox-debug": "GPS/u-blox panes (some may be empty if route lacks GPS data — that is acceptable)", + "new-layout": "this is an intentionally empty template layout — an empty plot pane is expected and correct", +} + + +def codex_review(image_paths: list[Path] | Path, prompt: str, timeout: float = 60.0) -> tuple[bool, str]: + """Ask codex to review screenshot(s). Returns (passed, explanation).""" + if isinstance(image_paths, Path): + image_paths = [image_paths] + cmd = ["codex", "exec", "-c", 'model_reasoning_effort="high"'] + for p in image_paths: + cmd.extend(["-i", str(p)]) + cmd.append("-") # read prompt from stdin + result = subprocess.run(cmd, input=prompt, capture_output=True, text=True, timeout=timeout) + answer = result.stdout.strip() + if not answer: + # codex may print the model answer in stderr after the banner + for line in reversed(result.stderr.strip().splitlines()): + stripped = line.strip() + if stripped and stripped not in ("codex", "") and not stripped.startswith(("tokens", "OpenAI", "---", "workdir", "model", "provider", "approval", "sandbox", "reasoning", "session")): + answer = stripped + break + passed = "PASS" in answer.upper().split("\n")[0] if answer else False + if not answer: + answer = f"(no response, rc={result.returncode}, stderr_tail={result.stderr[-300:]})" + return passed, answer + + +def _crop_image(src: Path, dst: Path, box: str): + """Crop an image using ImageMagick. box is WxH+X+Y.""" + subprocess.run(["convert", str(src), "-crop", box, str(dst)], check=True, timeout=10) + + +def _random_crops(src: Path, out_dir: Path, n: int, w: int, h: int, seed: int) -> list[Path]: + """Generate n random crops from src for detail inspection.""" + rng = random.Random(seed) + crop_w, crop_h = w // 2, h // 2 + paths = [] + for i in range(n): + x = rng.randint(0, w - crop_w) + y = rng.randint(0, h - crop_h) + p = out_dir / f"crop_{i}.png" + _crop_image(src, p, f"{crop_w}x{crop_h}+{x}+{y}") + paths.append(p) + return [p for p in paths if p.exists()] + + +LAYOUT_WIDTH, LAYOUT_HEIGHT = 1920, 1080 +NUM_CROPS = 3 + + +def run_layout_screenshot(layout: str): + session = Session(width=LAYOUT_WIDTH, height=LAYOUT_HEIGHT) + try: + session.start(route=f"{DEMO_ROUTE}/0/q", layout=layout, sync_load=True) + assert session.alive, f"jotpluggler crashed on startup: {session.crash_reason()}" + + shot = session.tmpdir / "screenshot.png" + session.screenshot(shot) + assert shot.stat().st_size > 1000, f"screenshot too small ({shot.stat().st_size}B)" + + crops = _random_crops(shot, session.tmpdir, NUM_CROPS, LAYOUT_WIDTH, LAYOUT_HEIGHT, seed=hash(layout)) + + specific = LAYOUT_CHECKS.get(layout, "") + crop_desc = " ".join(f"Image {i+2} is a random zoomed crop for detail inspection." for i in range(len(crops))) + prompt_parts = [ + f"You are reviewing the '{layout}' layout in jotpluggler, a vehicle telemetry plotting tool.", + f"Image 1 is the full screenshot. {crop_desc}", + "This uses a short demo route so some panes may lack data — that's OK.", + "Review and check the following. PASS only if the UI is structurally sound:", + "- the app rendered without crashing (not a blank/black window)", + "- plot panes are laid out correctly (not overlapping each other)", + "- text labels and legends are readable (not garbled or clipped by other elements)", + "- no UI elements overlapping other UI elements (e.g. buttons covering text, legends colliding with controls). in particular, check that close/dismiss buttons (X) have clear spacing from nearby text like legends — if they touch or crowd, that is a FAIL", + "- no rendering artifacts or broken UI elements", + "- the sidebar is visible on the left", + "- error dialogs about missing data or custom series are acceptable — not a failure", + "- some panes being empty due to limited demo data is acceptable — not a failure", + ] + if specific: + prompt_parts.append(f"- layout-specific: {specific}") + prompt_parts.append( + "Reply with exactly PASS or FAIL on the first line, then a one-sentence explanation." + ) + prompt = "\n".join(prompt_parts) + + passed, explanation = codex_review([shot, *crops], prompt) + print(f" [layout_{layout}] codex: {'PASS' if passed else 'FAIL'}") + print(f" {explanation}") + assert passed, f"codex review failed for layout '{layout}': {explanation}" + finally: + session.stop() + + +def _layout_names(): + return sorted(p.stem for p in LAYOUTS_DIR.glob("*.json")) + + +@pytest.mark.skipif(not BINARY.exists(), reason="jotpluggler binary not built") +class TestJotplugglerFuzz: + @pytest.mark.parametrize("scenario,round_id", _make_fuzz_params()) + def test_fuzz(self, scenario: Scenario, round_id: int): + run_fuzz(scenario, round_id) + + +@pytest.mark.skipif(not BINARY.exists(), reason="jotpluggler binary not built") +class TestJotplugglerLayouts: + @pytest.mark.parametrize("layout", _layout_names()) + def test_layout_screenshot(self, layout: str): + run_layout_screenshot(layout) diff --git a/tools/plotjuggler/layouts/controls_mismatch_debug.xml b/tools/plotjuggler/layouts/controls_mismatch_debug.xml index cf337aa7df7813..646e12a281a66b 100644 --- a/tools/plotjuggler/layouts/controls_mismatch_debug.xml +++ b/tools/plotjuggler/layouts/controls_mismatch_debug.xml @@ -16,7 +16,7 @@ - + @@ -58,3 +58,4 @@ + diff --git a/tools/plotjuggler/layouts/gps_vs_llk.xml b/tools/plotjuggler/layouts/gps_vs_llk.xml index 2051c2bef2db83..69b8f20058bbf9 100644 --- a/tools/plotjuggler/layouts/gps_vs_llk.xml +++ b/tools/plotjuggler/layouts/gps_vs_llk.xml @@ -24,8 +24,8 @@ - - + + @@ -72,11 +72,12 @@ return distance /gpsLocationExternal/latitude /gpsLocationExternal/longitude - /liveLocationKalmanDEPRECATED/positionGeodetic/value/0 - /liveLocationKalmanDEPRECATED/positionGeodetic/value/1 + /liveLocationKalman/positionGeodetic/value/0 + /liveLocationKalman/positionGeodetic/value/1 + diff --git a/tools/plotjuggler/layouts/system_lag_debug.xml b/tools/plotjuggler/layouts/system_lag_debug.xml index 88511ffe09bbe2..a90bba0e279627 100644 --- a/tools/plotjuggler/layouts/system_lag_debug.xml +++ b/tools/plotjuggler/layouts/system_lag_debug.xml @@ -45,7 +45,7 @@ - + @@ -64,3 +64,4 @@ + diff --git a/tools/plotjuggler/layouts/tuning.xml b/tools/plotjuggler/layouts/tuning.xml index 699f6ff683ea32..503e726caf46d6 100644 --- a/tools/plotjuggler/layouts/tuning.xml +++ b/tools/plotjuggler/layouts/tuning.xml @@ -24,14 +24,14 @@ - + - + @@ -39,7 +39,7 @@ - + @@ -71,7 +71,7 @@ - + @@ -126,7 +126,7 @@ - + @@ -161,11 +161,11 @@ if (time > last_bad_time + engage_delay) then else return 0 end - /carControl/angularVelocity/2 + /liveLocationKalman/angularVelocityCalibrated/value/2 /carState/steeringPressed /carControl/enabled - /carState/vEgo + /liveLocationKalman/velocityCalibrated/value/0 @@ -206,7 +206,7 @@ if (time > last_bad_time + engage_delay) then else return 0 end - /modelV2/action/desiredCurvature + /lateralPlan/curvatures/0 /carState/steeringPressed /carControl/enabled @@ -284,17 +284,8 @@ end /carControl/enabled - - - return (math.abs(value - v1) > 0.001 or math.abs(v2 - v3) > 0.05) and 1 or 0 - /carControl/actuators/torque - - /carOutput/actuatorsOutput/torque - /carControl/actuators/steeringAngleDeg - /carOutput/actuatorsOutput/steeringAngleDeg - - +