mirror of
https://github.com/dzid26/sunnypilot.git
synced 2026-06-08 07:44:55 +08:00
jotpluggler: part one (#37730)
This commit is contained in:
@@ -49,7 +49,7 @@ pkgs = [importlib.import_module(name) for name in pkg_names]
|
||||
allowed_system_libs = {
|
||||
"EGL", "GLESv2", "GL",
|
||||
"Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets",
|
||||
"dl", "drm", "gbm", "m", "pthread",
|
||||
"dl", "drm", "gbm", "m", "pthread",
|
||||
}
|
||||
|
||||
def _resolve_lib(env, name):
|
||||
@@ -259,6 +259,7 @@ if arch != "larch64":
|
||||
SConscript([
|
||||
'tools/replay/SConscript',
|
||||
'tools/cabana/SConscript',
|
||||
'tools/jotpluggler/SConscript',
|
||||
])
|
||||
|
||||
|
||||
|
||||
9
tools/jotpluggler/.gitignore
vendored
Normal file
9
tools/jotpluggler/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
jot_*.o
|
||||
*.o
|
||||
jotpluggler
|
||||
car_fingerprint_to_dbc.h
|
||||
generated_dbcs/.stamp
|
||||
generated_dbcs/*.dbc
|
||||
layouts/.jotpluggler_autosave/
|
||||
reports/
|
||||
92
tools/jotpluggler/SConscript
Normal file
92
tools/jotpluggler/SConscript
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import imgui
|
||||
import libusb
|
||||
from opendbc import get_generated_dbcs
|
||||
from opendbc.car import Bus
|
||||
from opendbc.car.fingerprints import MIGRATION
|
||||
from opendbc.car.values import PLATFORMS
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
|
||||
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib')
|
||||
|
||||
jot_env = env.Clone()
|
||||
jot_env["LIBPATH"] += [imgui.MESA_DIR, libusb.LIB_DIR]
|
||||
jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR]
|
||||
jot_env["CXXFLAGS"] += [
|
||||
"-DGLFW_INCLUDE_NONE",
|
||||
'-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR),
|
||||
]
|
||||
|
||||
def materialize_generated_dbcs(target, source, env):
|
||||
out_dir = os.path.dirname(str(target[0]))
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
for name in os.listdir(out_dir):
|
||||
if name.endswith('.dbc'):
|
||||
os.unlink(os.path.join(out_dir, name))
|
||||
|
||||
for name, content in sorted(get_generated_dbcs().items()):
|
||||
with open(os.path.join(out_dir, f"{name}.dbc"), "w") as f:
|
||||
f.write(content)
|
||||
|
||||
with open(str(target[0]), "w") as f:
|
||||
f.write("ok\n")
|
||||
|
||||
return None
|
||||
|
||||
def write_car_fingerprint_to_dbc_header(target, source, env):
|
||||
pairs = {}
|
||||
|
||||
for name, platform in sorted(PLATFORMS.items()):
|
||||
dbc = platform.config.dbc_dict.get(Bus.pt, "")
|
||||
if not dbc and name.startswith("TESLA_"):
|
||||
dbc = platform.config.dbc_dict.get(Bus.party, "")
|
||||
if not dbc and name == "COMMA_BODY":
|
||||
dbc = "comma_body"
|
||||
if dbc and name != "MOCK":
|
||||
pairs[name] = dbc
|
||||
|
||||
for fingerprint, car in sorted(MIGRATION.items()):
|
||||
dbc = pairs.get(str(car), "")
|
||||
if dbc:
|
||||
pairs[fingerprint] = dbc
|
||||
|
||||
lines = [
|
||||
"#pragma once",
|
||||
"",
|
||||
"#include <string_view>",
|
||||
"#include <utility>",
|
||||
"",
|
||||
"inline constexpr std::pair<std::string_view, std::string_view> kCarFingerprintToDbc[] = {",
|
||||
]
|
||||
lines.extend(f' {{"{fingerprint}", "{dbc}"}},' for fingerprint, dbc in sorted(pairs.items()))
|
||||
lines.extend([
|
||||
"};",
|
||||
"",
|
||||
"inline std::string_view dbc_for_car_fingerprint(std::string_view fingerprint) {",
|
||||
" for (const auto &[car_fingerprint, dbc] : kCarFingerprintToDbc) {",
|
||||
" if (car_fingerprint == fingerprint) return dbc;",
|
||||
" }",
|
||||
" return {};",
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
|
||||
with open(str(target[0]), "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
return None
|
||||
|
||||
generated_dbc_stamp = jot_env.Command(f"generated_dbcs/.stamp", [], materialize_generated_dbcs)
|
||||
car_fingerprint_to_dbc = jot_env.Command("car_fingerprint_to_dbc.h", [], write_car_fingerprint_to_dbc_header)
|
||||
|
||||
libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"),
|
||||
"avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "m", "pthread", "usb-1.0"]
|
||||
if arch == "Darwin":
|
||||
jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"]
|
||||
else:
|
||||
libs += ["GL", "dl", "va", "va-drm", "drm"]
|
||||
|
||||
program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs)
|
||||
jot_env.Depends(program, generated_dbc_stamp)
|
||||
jot_env.Depends(program, car_fingerprint_to_dbc)
|
||||
1914
tools/jotpluggler/app.cc
Normal file
1914
tools/jotpluggler/app.cc
Normal file
File diff suppressed because it is too large
Load Diff
884
tools/jotpluggler/app.h
Normal file
884
tools/jotpluggler/app.h
Normal file
@@ -0,0 +1,884 @@
|
||||
#pragma once
|
||||
|
||||
#include "cereal/gen/cpp/log.capnp.h"
|
||||
#include "imgui.h"
|
||||
#include "tools/jotpluggler/dbc.h"
|
||||
#include "tools/jotpluggler/util.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// *****
|
||||
// app options & entry point
|
||||
// *****
|
||||
|
||||
struct Options {
|
||||
std::string layout;
|
||||
std::string route_name;
|
||||
std::string data_dir;
|
||||
std::string output_path;
|
||||
std::string stream_address = "127.0.0.1";
|
||||
int width = 1600;
|
||||
int height = 900;
|
||||
bool show = false;
|
||||
bool sync_load = false;
|
||||
bool stream = false;
|
||||
double stream_buffer_seconds = 30.0;
|
||||
};
|
||||
|
||||
int run(const Options &options);
|
||||
|
||||
// *****
|
||||
// sketch layout & route data
|
||||
// *****
|
||||
|
||||
struct PlotRange {
|
||||
bool valid = false;
|
||||
double left = 0.0;
|
||||
double right = 0.0;
|
||||
double bottom = 0.0;
|
||||
double top = 1.0;
|
||||
bool has_y_limit_min = false;
|
||||
bool has_y_limit_max = false;
|
||||
double y_limit_min = 0.0;
|
||||
double y_limit_max = 1.0;
|
||||
};
|
||||
|
||||
struct CustomPythonSeries {
|
||||
std::string linked_source;
|
||||
std::vector<std::string> additional_sources;
|
||||
std::string globals_code;
|
||||
std::string function_code;
|
||||
};
|
||||
|
||||
struct Curve {
|
||||
std::string name;
|
||||
std::string label;
|
||||
std::array<uint8_t, 3> 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<CustomPythonSeries> custom_python;
|
||||
std::string runtime_error_message;
|
||||
std::vector<double> xs;
|
||||
std::vector<double> 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<Curve> curves;
|
||||
};
|
||||
|
||||
enum class SplitOrientation {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
};
|
||||
|
||||
struct WorkspaceNode {
|
||||
bool is_pane = false;
|
||||
int pane_index = -1;
|
||||
SplitOrientation orientation = SplitOrientation::Horizontal;
|
||||
std::vector<float> sizes;
|
||||
std::vector<WorkspaceNode> children;
|
||||
};
|
||||
|
||||
struct WorkspaceTab {
|
||||
std::string tab_name;
|
||||
WorkspaceNode root;
|
||||
std::vector<Pane> panes;
|
||||
};
|
||||
|
||||
struct RouteSeries {
|
||||
std::string path;
|
||||
std::vector<double> times;
|
||||
std::vector<double> 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<CameraSegmentFile> segment_files;
|
||||
std::vector<CameraFrameIndexEntry> 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<std::string> 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<size_t>(id.service) << 40)
|
||||
^ (static_cast<size_t>(id.bus) << 32)
|
||||
^ static_cast<size_t>(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<CanFrameSample> 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<GpsPoint> 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<RouteSeries> series;
|
||||
std::vector<std::string> paths;
|
||||
std::vector<std::string> roots;
|
||||
std::vector<CanMessageData> can_messages;
|
||||
CameraFeedIndex road_camera;
|
||||
CameraFeedIndex driver_camera;
|
||||
CameraFeedIndex wide_road_camera;
|
||||
CameraFeedIndex qroad_camera;
|
||||
GpsTrace gps_trace;
|
||||
std::vector<LogEntry> logs;
|
||||
std::vector<TimelineEntry> timeline;
|
||||
std::unordered_map<std::string, EnumInfo> enum_info;
|
||||
std::unordered_map<std::string, SeriesFormat> 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<RouteSeries> series;
|
||||
std::vector<CanMessageData> can_messages;
|
||||
std::vector<LogEntry> logs;
|
||||
std::vector<TimelineEntry> timeline;
|
||||
std::unordered_map<std::string, EnumInfo> enum_info;
|
||||
std::string car_fingerprint;
|
||||
std::string dbc_name;
|
||||
bool has_time_offset = false;
|
||||
double time_offset = 0.0;
|
||||
};
|
||||
|
||||
struct SketchLayout {
|
||||
std::vector<WorkspaceTab> tabs;
|
||||
std::vector<std::string> 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<void(const RouteLoadProgress &)>;
|
||||
|
||||
class StreamAccumulator {
|
||||
public:
|
||||
explicit StreamAccumulator(const std::string &dbc_name = {}, std::optional<double> 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<const capnp::word> data);
|
||||
void appendCanFrames(CanServiceKind service, const std::vector<LiveCanFrame> &frames);
|
||||
StreamExtractBatch takeBatch();
|
||||
const std::string &carFingerprint() const;
|
||||
const std::string &dbc_name() const;
|
||||
std::optional<double> timeOffset() const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
SketchLayout load_sketch_layout(const std::filesystem::path &layout_path);
|
||||
std::vector<std::string> available_dbc_names();
|
||||
std::vector<std::string> collect_route_roots_for_paths(const std::vector<std::string> &paths);
|
||||
std::optional<dbc::Database> load_dbc_by_name(const std::string &dbc_name);
|
||||
std::vector<RouteSeries> decode_can_messages(const std::vector<CanMessageData> &can_messages,
|
||||
const std::string &dbc_name,
|
||||
std::unordered_map<std::string, EnumInfo> *enum_info = nullptr);
|
||||
RouteData load_route_data(const std::string &route_name,
|
||||
const std::string &data_dir = {},
|
||||
const std::string &dbc_name = {},
|
||||
const RouteLoadProgressCallback &progress = {});
|
||||
RouteIdentifier parse_route_identifier(std::string_view route_name);
|
||||
void rebuild_gps_trace(RouteData *route_data);
|
||||
|
||||
// *****
|
||||
// icons
|
||||
// *****
|
||||
|
||||
namespace icon {
|
||||
constexpr const char ARROW_DOWN_UP[] = "\xef\x84\xa7";
|
||||
constexpr const char ARROW_LEFT_RIGHT[] = "\xef\x84\xab";
|
||||
constexpr const char BAR_CHART[] = "\xef\x85\xbe";
|
||||
constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85";
|
||||
constexpr const char CLIPBOARD[] = "\xef\x8a\x90";
|
||||
constexpr const char CLIPBOARD2[] = "\xef\x9c\xb3";
|
||||
constexpr const char DISTRIBUTE_HORIZONTAL[] = "\xef\x8c\x83";
|
||||
constexpr const char DISTRIBUTE_VERTICAL[] = "\xef\x8c\x84";
|
||||
constexpr const char FILE_EARMARK_IMAGE[] = "\xef\x8d\xad";
|
||||
constexpr const char FILES[] = "\xef\x8f\x82";
|
||||
constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1";
|
||||
constexpr const char PALETTE[] = "\xef\x92\xb1";
|
||||
constexpr const char PLUS_SLASH_MINUS[] = "\xef\x9a\xaa";
|
||||
constexpr const char SAVE[] = "\xef\x94\xa5";
|
||||
constexpr const char SLIDERS[] = "\xef\x95\xab";
|
||||
constexpr const char TRASH[] = "\xef\x97\x9e";
|
||||
constexpr const char X_SQUARE[] = "\xef\x98\xa9";
|
||||
constexpr const char ZOOM_OUT[] = "\xef\x98\xad";
|
||||
} // namespace icon
|
||||
|
||||
void icon_add_font(float size, bool merge = false, const ImFont *base_font = nullptr);
|
||||
bool icon_menu_item(const char *glyph,
|
||||
const char *label,
|
||||
const char *shortcut = nullptr,
|
||||
bool selected = false,
|
||||
bool enabled = true);
|
||||
|
||||
// *****
|
||||
// app session, UI state, & internal API
|
||||
// *****
|
||||
|
||||
class AsyncRouteLoader;
|
||||
class CameraFeedView;
|
||||
class StreamPoller;
|
||||
class MapDataManager;
|
||||
|
||||
enum class SessionDataMode : uint8_t {
|
||||
Route,
|
||||
Stream,
|
||||
};
|
||||
|
||||
enum class StreamSourceKind : uint8_t {
|
||||
CerealLocal,
|
||||
CerealRemote,
|
||||
};
|
||||
|
||||
struct StreamSourceConfig {
|
||||
StreamSourceKind kind = StreamSourceKind::CerealLocal;
|
||||
std::string address = "127.0.0.1";
|
||||
};
|
||||
|
||||
struct BrowserNode {
|
||||
std::string label;
|
||||
std::string full_path;
|
||||
std::vector<BrowserNode> children;
|
||||
};
|
||||
|
||||
struct AppSession {
|
||||
std::filesystem::path layout_path;
|
||||
std::filesystem::path autosave_path;
|
||||
std::string route_name;
|
||||
std::string data_dir;
|
||||
std::string dbc_override;
|
||||
StreamSourceConfig stream_source;
|
||||
double stream_buffer_seconds = 30.0;
|
||||
SessionDataMode data_mode = SessionDataMode::Route;
|
||||
RouteIdentifier route_id;
|
||||
SketchLayout layout;
|
||||
RouteData route_data;
|
||||
std::unordered_map<std::string, RouteSeries *> series_by_path;
|
||||
std::vector<BrowserNode> browser_nodes;
|
||||
std::unique_ptr<AsyncRouteLoader> route_loader;
|
||||
std::unique_ptr<StreamPoller> stream_poller;
|
||||
std::array<std::unique_ptr<CameraFeedView>, 4> pane_camera_feeds;
|
||||
std::unique_ptr<MapDataManager> map_data;
|
||||
bool async_route_loading = false;
|
||||
double next_stream_custom_refresh_time = 0.0;
|
||||
bool stream_paused = false;
|
||||
std::optional<double> 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<MapPaneState> map_panes;
|
||||
std::vector<CameraPaneState> 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<std::string> additional_sources;
|
||||
std::string globals_code;
|
||||
std::string function_code = "return value";
|
||||
std::string preview_label;
|
||||
std::vector<double> preview_xs;
|
||||
std::vector<double> 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<std::string> selected_sources;
|
||||
double last_auto_scroll_time = -1.0;
|
||||
LogTimeMode time_mode = LogTimeMode::Route;
|
||||
};
|
||||
|
||||
struct AxisLimitsEditorState {
|
||||
bool open = false;
|
||||
int pane_index = -1;
|
||||
double x_min = 0.0;
|
||||
double x_max = 1.0;
|
||||
bool y_min_enabled = false;
|
||||
bool y_max_enabled = false;
|
||||
double y_min = 0.0;
|
||||
double y_max = 1.0;
|
||||
};
|
||||
|
||||
struct DbcEditorState {
|
||||
bool open = false;
|
||||
bool loaded = false;
|
||||
std::string source_name;
|
||||
std::filesystem::path source_path;
|
||||
enum class SourceKind : uint8_t {
|
||||
None,
|
||||
Generated,
|
||||
Opendbc,
|
||||
};
|
||||
SourceKind source_kind = SourceKind::None;
|
||||
std::string save_name;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
enum class TimelineDragMode : uint8_t {
|
||||
None,
|
||||
ScrubCursor,
|
||||
PanViewport,
|
||||
ResizeLeft,
|
||||
ResizeRight,
|
||||
};
|
||||
|
||||
struct UndoStack {
|
||||
static constexpr size_t kMaxHistory = 50;
|
||||
|
||||
std::vector<SketchLayout> 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<int>(history.size())) {
|
||||
history.resize(static_cast<size_t>(position + 1));
|
||||
}
|
||||
history.push_back(layout);
|
||||
if (history.size() > kMaxHistory) {
|
||||
history.erase(history.begin());
|
||||
}
|
||||
position = static_cast<int>(history.size()) - 1;
|
||||
}
|
||||
|
||||
bool can_undo() const {
|
||||
return position > 0;
|
||||
}
|
||||
|
||||
bool can_redo() const {
|
||||
return position >= 0 && position + 1 < static_cast<int>(history.size());
|
||||
}
|
||||
|
||||
const SketchLayout &undo() {
|
||||
return history[static_cast<size_t>(--position)];
|
||||
}
|
||||
|
||||
const SketchLayout &redo() {
|
||||
return history[static_cast<size_t>(++position)];
|
||||
}
|
||||
};
|
||||
|
||||
struct UiState {
|
||||
bool open_open_route = false;
|
||||
bool open_stream = false;
|
||||
bool open_load_layout = false;
|
||||
bool open_save_layout = false;
|
||||
bool open_preferences = false;
|
||||
bool open_find_signal = false;
|
||||
bool request_close = false;
|
||||
bool request_reset_layout = false;
|
||||
bool request_save_layout = false;
|
||||
bool request_new_tab = false;
|
||||
bool request_duplicate_tab = false;
|
||||
bool request_close_tab = false;
|
||||
bool follow_latest = false;
|
||||
bool has_shared_range = false;
|
||||
bool has_tracker_time = false;
|
||||
bool layout_dirty = false;
|
||||
bool playback_loop = false;
|
||||
bool playback_playing = false;
|
||||
bool show_deprecated_fields = false;
|
||||
bool show_fps_overlay = false;
|
||||
bool fps_overlay_initialized = false;
|
||||
bool suppress_range_side_effects = false;
|
||||
bool browser_nodes_dirty = false;
|
||||
int active_tab_index = 0;
|
||||
int next_tab_runtime_id = 1;
|
||||
int requested_tab_index = -1;
|
||||
int rename_tab_index = -1;
|
||||
bool focus_rename_tab_input = false;
|
||||
std::vector<TabUiState> tabs;
|
||||
std::string route_buffer;
|
||||
std::string stream_address_buffer;
|
||||
std::string rename_tab_buffer;
|
||||
std::string browser_filter;
|
||||
std::string data_dir_buffer;
|
||||
std::string load_layout_buffer;
|
||||
std::string save_layout_buffer;
|
||||
std::string find_signal_buffer;
|
||||
std::string selected_browser_path;
|
||||
std::vector<std::string> selected_browser_paths;
|
||||
std::string browser_selection_anchor;
|
||||
std::string route_slice_buffer;
|
||||
std::string error_text;
|
||||
bool open_error_popup = false;
|
||||
std::string status_text = "Ready";
|
||||
std::string route_copy_feedback_text;
|
||||
double route_copy_feedback_until = 0.0;
|
||||
bool editing_route_slice = false;
|
||||
bool focus_route_slice_input = false;
|
||||
StreamSourceKind stream_source_kind = StreamSourceKind::CerealLocal;
|
||||
float sidebar_width = 320.0f;
|
||||
double route_x_min = 0.0;
|
||||
double route_x_max = 1.0;
|
||||
double x_view_min = 0.0;
|
||||
double x_view_max = 1.0;
|
||||
double tracker_time = 0.0;
|
||||
double playback_rate = 1.0;
|
||||
double playback_step = 0.1;
|
||||
double stream_buffer_seconds = 30.0;
|
||||
TimelineDragMode timeline_drag_mode = TimelineDragMode::None;
|
||||
double timeline_drag_anchor_time = 0.0;
|
||||
double timeline_drag_anchor_x_min = 0.0;
|
||||
double timeline_drag_anchor_x_max = 0.0;
|
||||
AxisLimitsEditorState axis_limits;
|
||||
DbcEditorState dbc_editor;
|
||||
CustomSeriesEditorState custom_series;
|
||||
LogsUiState logs;
|
||||
UndoStack undo;
|
||||
};
|
||||
|
||||
// app.cc public API
|
||||
|
||||
const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state);
|
||||
WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state);
|
||||
TabUiState *app_active_tab_state(UiState *state);
|
||||
|
||||
void app_push_mono_font();
|
||||
void app_pop_mono_font();
|
||||
bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path);
|
||||
|
||||
std::string app_curve_display_name(const Curve &curve);
|
||||
std::array<uint8_t, 3> 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<double> &xs_in,
|
||||
const std::vector<double> &ys_in,
|
||||
int max_points,
|
||||
std::vector<double> *xs_out,
|
||||
std::vector<double> *ys_out);
|
||||
std::optional<double> app_sample_xy_value_at_time(const std::vector<double> &xs,
|
||||
const std::vector<double> &ys,
|
||||
bool stairs,
|
||||
double tm);
|
||||
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<double> &values, bool enum_like = false);
|
||||
std::string format_display_value(double display_value,
|
||||
const SeriesFormat &format,
|
||||
const EnumInfo *enum_info);
|
||||
std::vector<std::string> decode_browser_drag_payload(std::string_view payload);
|
||||
void collect_visible_leaf_paths(const BrowserNode &node,
|
||||
const std::string &filter,
|
||||
std::vector<std::string> *out);
|
||||
void draw_browser_node(AppSession *session,
|
||||
const BrowserNode &node,
|
||||
UiState *state,
|
||||
const std::string &filter,
|
||||
const std::vector<std::string> &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> 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> 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<double> 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> 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> impl_;
|
||||
};
|
||||
465
tools/jotpluggler/browser.cc
Normal file
465
tools/jotpluggler/browser.cc
Normal file
@@ -0,0 +1,465 @@
|
||||
#include "tools/jotpluggler/app.h"
|
||||
|
||||
#include "imgui_internal.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float BROWSER_VALUE_WIDTH = 88.0f;
|
||||
|
||||
bool path_matches_filter(const std::string &path, const std::string &lower_filter) {
|
||||
if (lower_filter.empty()) return true;
|
||||
return lowercase_copy(path).find(lower_filter) != std::string::npos;
|
||||
}
|
||||
|
||||
void insert_browser_path(std::vector<BrowserNode> *nodes, const std::string &path) {
|
||||
size_t start = 0;
|
||||
while (start < path.size() && path[start] == '/') {
|
||||
++start;
|
||||
}
|
||||
std::vector<std::string> 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<BrowserNode> *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<BrowserNode> *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<BrowserNode> build_browser_tree(const std::vector<std::string> &paths) {
|
||||
std::vector<BrowserNode> 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<std::string> visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) {
|
||||
if (show_deprecated_fields) return route_data.paths;
|
||||
std::vector<std::string> 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<std::string> 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<std::string> &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<std::string> &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<std::string> selected;
|
||||
selected.reserve(static_cast<size_t>(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<std::string> &visible_paths) {
|
||||
const std::unordered_set<std::string> 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<double> 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<double> 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<double> &values, bool enum_like) {
|
||||
SeriesFormat format;
|
||||
if (values.empty()) return format;
|
||||
|
||||
const size_t step = std::max<size_t>(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<int>(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<int>(std::llround(display_value));
|
||||
if (idx >= 0 && std::abs(display_value - static_cast<double>(idx)) < 0.01
|
||||
&& static_cast<size_t>(idx) < enum_info->names.size()
|
||||
&& !enum_info->names[static_cast<size_t>(idx)].empty()) {
|
||||
return enum_info->names[static_cast<size_t>(idx)];
|
||||
}
|
||||
}
|
||||
char buf[64] = {};
|
||||
std::snprintf(buf, sizeof(buf), display_info.fmt, display_value);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::vector<std::string> decode_browser_drag_payload(std::string_view payload) {
|
||||
std::vector<std::string> 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<std::string> *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<std::string> 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<std::string> &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<std::string> 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();
|
||||
}
|
||||
}
|
||||
54
tools/jotpluggler/camera.cc
Normal file
54
tools/jotpluggler/camera.cc
Normal file
@@ -0,0 +1,54 @@
|
||||
#include "tools/jotpluggler/camera.h"
|
||||
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool draw_camera_fit_toggle_overlay(bool fit_to_pane) {
|
||||
const ImVec2 window_pos = ImGui::GetWindowPos();
|
||||
const ImVec2 content_min = ImGui::GetWindowContentRegionMin();
|
||||
const ImRect rect(ImVec2(window_pos.x + content_min.x + 8.0f, window_pos.y + content_min.y + 8.0f),
|
||||
ImVec2(window_pos.x + content_min.x + 58.0f, window_pos.y + content_min.y + 28.0f));
|
||||
const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false);
|
||||
const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left);
|
||||
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
ImDrawList *draw_list = ImGui::GetWindowDrawList();
|
||||
draw_list->AddRectFilled(rect.Min, rect.Max, hovered ? IM_COL32(255, 255, 255, 234) : IM_COL32(255, 255, 255, 214), 4.0f);
|
||||
draw_list->AddRect(rect.Min, rect.Max, IM_COL32(184, 189, 196, 255), 4.0f, 0, 1.0f);
|
||||
const ImRect box(ImVec2(rect.Min.x + 6.0f, rect.Min.y + 4.0f), ImVec2(rect.Min.x + 18.0f, rect.Min.y + 16.0f));
|
||||
draw_list->AddRect(box.Min, box.Max, IM_COL32(112, 120, 129, 255), 2.0f, 0, 1.0f);
|
||||
if (fit_to_pane) {
|
||||
draw_list->AddLine(ImVec2(box.Min.x + 2.5f, box.Min.y + 6.5f), ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
|
||||
draw_list->AddLine(ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), ImVec2(box.Max.x - 2.5f, box.Min.y + 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
|
||||
}
|
||||
draw_list->AddText(ImVec2(box.Max.x + 6.0f, rect.Min.y + 3.0f), IM_COL32(72, 79, 88, 255), "Fit");
|
||||
return hovered && !held && ImGui::IsMouseReleased(ImGuiMouseButton_Left);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane) {
|
||||
CameraFeedView *feed = session->pane_camera_feeds[static_cast<size_t>(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<int>(tab_state->camera_panes.size())
|
||||
? tab_state->camera_panes[static_cast<size_t>(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<int>(tab_state->camera_panes.size())
|
||||
&& draw_camera_fit_toggle_overlay(fit_to_pane)) {
|
||||
tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane = !fit_to_pane;
|
||||
}
|
||||
}
|
||||
5
tools/jotpluggler/camera.h
Normal file
5
tools/jotpluggler/camera.h
Normal file
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "tools/jotpluggler/app.h"
|
||||
|
||||
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane);
|
||||
179
tools/jotpluggler/common.cc
Normal file
179
tools/jotpluggler/common.cc
Normal file
@@ -0,0 +1,179 @@
|
||||
#include "tools/jotpluggler/common.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string format_coord(const GpsPoint &point) {
|
||||
return util::string_format("%.5f,%.5f", point.lat, point.lon);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const CameraViewSpec &camera_view_spec(CameraViewKind view) {
|
||||
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
|
||||
return spec.view == view;
|
||||
});
|
||||
return it != kCameraViewSpecs.end() ? *it : kCameraViewSpecs.front();
|
||||
}
|
||||
|
||||
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id) {
|
||||
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
|
||||
return item_id == spec.special_item_id;
|
||||
});
|
||||
return it != kCameraViewSpecs.end() ? &*it : nullptr;
|
||||
}
|
||||
|
||||
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name) {
|
||||
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
|
||||
return layout_name == spec.layout_name;
|
||||
});
|
||||
return it != kCameraViewSpecs.end() ? &*it : nullptr;
|
||||
}
|
||||
|
||||
const SpecialItemSpec *special_item_spec(std::string_view item_id) {
|
||||
auto it = std::find_if(kSpecialItemSpecs.begin(), kSpecialItemSpecs.end(), [&](const SpecialItemSpec &spec) {
|
||||
return item_id == spec.id;
|
||||
});
|
||||
return it != kSpecialItemSpecs.end() ? &*it : nullptr;
|
||||
}
|
||||
|
||||
const char *special_item_label(std::string_view item_id) {
|
||||
const SpecialItemSpec *spec = special_item_spec(item_id);
|
||||
return spec != nullptr ? spec->label : "Item";
|
||||
}
|
||||
|
||||
bool pane_kind_is_special(PaneKind kind) {
|
||||
return kind == PaneKind::Map || kind == PaneKind::Camera;
|
||||
}
|
||||
|
||||
bool is_default_special_title(std::string_view title) {
|
||||
if (title == "Map") return true;
|
||||
return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
|
||||
return title == spec.label;
|
||||
});
|
||||
}
|
||||
|
||||
CameraViewKind sidebar_preview_camera_view(const AppSession &session) {
|
||||
return session.route_data.road_camera.entries.empty() && !session.route_data.qroad_camera.entries.empty()
|
||||
? CameraViewKind::QRoad
|
||||
: CameraViewKind::Road;
|
||||
}
|
||||
|
||||
const std::filesystem::path &repo_root() {
|
||||
static const std::filesystem::path root(JOTP_REPO_ROOT);
|
||||
return root;
|
||||
}
|
||||
|
||||
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha) {
|
||||
return timeline_entry_color(type, alpha, {111, 143, 175});
|
||||
}
|
||||
|
||||
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color) {
|
||||
switch (type) {
|
||||
case TimelineEntry::Type::Engaged:
|
||||
return ImGui::GetColorU32(color_rgb(0, 163, 108, alpha));
|
||||
case TimelineEntry::Type::AlertInfo:
|
||||
return ImGui::GetColorU32(color_rgb(255, 195, 0, alpha));
|
||||
case TimelineEntry::Type::AlertWarning:
|
||||
case TimelineEntry::Type::AlertCritical:
|
||||
return ImGui::GetColorU32(color_rgb(199, 0, 57, alpha));
|
||||
case TimelineEntry::Type::None:
|
||||
default:
|
||||
return ImGui::GetColorU32(color_rgb(none_color, alpha));
|
||||
}
|
||||
}
|
||||
|
||||
const char *timeline_entry_label(TimelineEntry::Type type) {
|
||||
static constexpr const char *kLabels[] = {
|
||||
"disengaged",
|
||||
"engaged",
|
||||
"alert info",
|
||||
"alert warning",
|
||||
"alert critical",
|
||||
};
|
||||
const size_t index = static_cast<size_t>(type);
|
||||
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
|
||||
}
|
||||
|
||||
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value) {
|
||||
for (const TimelineEntry &entry : timeline) {
|
||||
if (time_value >= entry.start_time && time_value <= entry.end_time) {
|
||||
return entry.type;
|
||||
}
|
||||
}
|
||||
return TimelineEntry::Type::None;
|
||||
}
|
||||
|
||||
std::string normalize_stream_address(std::string address) {
|
||||
return is_local_stream_address(address) ? "127.0.0.1" : address;
|
||||
}
|
||||
|
||||
const char *stream_source_kind_label(StreamSourceKind kind) {
|
||||
static constexpr const char *kLabels[] = {
|
||||
"Local (MSGQ)",
|
||||
"Remote (ZMQ)",
|
||||
};
|
||||
const size_t index = static_cast<size_t>(kind);
|
||||
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
|
||||
}
|
||||
|
||||
std::string stream_source_target_label(const StreamSourceConfig &source) {
|
||||
switch (source.kind) {
|
||||
case StreamSourceKind::CerealRemote:
|
||||
return normalize_stream_address(source.address);
|
||||
case StreamSourceKind::CerealLocal:
|
||||
default:
|
||||
return "127.0.0.1";
|
||||
}
|
||||
}
|
||||
|
||||
bool env_flag_enabled(const char *name, bool default_value) {
|
||||
const char *raw = std::getenv(name);
|
||||
if (raw == nullptr || raw[0] == '\0') {
|
||||
return default_value;
|
||||
}
|
||||
const std::string value = lowercase_copy(util::strip(raw));
|
||||
return !(value == "0" || value == "false" || value == "no" || value == "off");
|
||||
}
|
||||
|
||||
void open_external_url(std::string_view url) {
|
||||
#ifdef __APPLE__
|
||||
const std::string command = "open " + shell_quote(url) + " &";
|
||||
#else
|
||||
const std::string command = "xdg-open " + shell_quote(url) + " >/dev/null 2>&1 &";
|
||||
#endif
|
||||
util::check_system(command);
|
||||
}
|
||||
|
||||
std::string route_useradmin_url(const RouteIdentifier &route_id) {
|
||||
return route_id.empty() ? std::string()
|
||||
: "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id;
|
||||
}
|
||||
|
||||
std::string route_connect_url(const RouteIdentifier &route_id) {
|
||||
return route_id.empty() ? std::string()
|
||||
: "https://connect.comma.ai/" + route_id.canonical();
|
||||
}
|
||||
|
||||
std::string route_google_maps_url(const GpsTrace &trace) {
|
||||
if (trace.points.size() < 2) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::string prefix = "https://www.google.com/maps/dir/?api=1&travelmode=driving&origin="
|
||||
+ format_coord(trace.points.front()) + "&destination=" + format_coord(trace.points.back());
|
||||
for (size_t n = std::min<size_t>(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;
|
||||
}
|
||||
}
|
||||
63
tools/jotpluggler/common.h
Normal file
63
tools/jotpluggler/common.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "tools/jotpluggler/app.h"
|
||||
|
||||
#include <array>
|
||||
#include <string_view>
|
||||
|
||||
struct CameraViewSpec {
|
||||
CameraViewKind view = CameraViewKind::Road;
|
||||
const char *label = "";
|
||||
const char *runtime_name = "";
|
||||
const char *layout_name = "";
|
||||
const char *special_item_id = "";
|
||||
CameraFeedIndex RouteData::*route_member = nullptr;
|
||||
};
|
||||
|
||||
struct SpecialItemSpec {
|
||||
const char *id = "";
|
||||
const char *label = "";
|
||||
PaneKind kind = PaneKind::Plot;
|
||||
CameraViewKind camera_view = CameraViewKind::Road;
|
||||
};
|
||||
|
||||
inline constexpr std::array<CameraViewSpec, 4> kCameraViewSpecs = {{
|
||||
{CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera},
|
||||
{CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera},
|
||||
{CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera},
|
||||
{CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera},
|
||||
}};
|
||||
|
||||
inline constexpr std::array<SpecialItemSpec, 5> kSpecialItemSpecs = {{
|
||||
{"map", "Map", PaneKind::Map, CameraViewKind::Road},
|
||||
{kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view},
|
||||
{kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view},
|
||||
{kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view},
|
||||
{kCameraViewSpecs[3].special_item_id, kCameraViewSpecs[3].label, PaneKind::Camera, kCameraViewSpecs[3].view},
|
||||
}};
|
||||
|
||||
const CameraViewSpec &camera_view_spec(CameraViewKind view);
|
||||
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id);
|
||||
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name);
|
||||
|
||||
const SpecialItemSpec *special_item_spec(std::string_view item_id);
|
||||
const char *special_item_label(std::string_view item_id);
|
||||
|
||||
bool pane_kind_is_special(PaneKind kind);
|
||||
bool is_default_special_title(std::string_view title);
|
||||
CameraViewKind sidebar_preview_camera_view(const AppSession &session);
|
||||
const std::filesystem::path &repo_root();
|
||||
|
||||
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha = 1.0f);
|
||||
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color);
|
||||
const char *timeline_entry_label(TimelineEntry::Type type);
|
||||
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value);
|
||||
std::string normalize_stream_address(std::string address);
|
||||
const char *stream_source_kind_label(StreamSourceKind kind);
|
||||
std::string stream_source_target_label(const StreamSourceConfig &source);
|
||||
|
||||
bool env_flag_enabled(const char *name, bool default_value = false);
|
||||
void open_external_url(std::string_view url);
|
||||
std::string route_useradmin_url(const RouteIdentifier &route_id);
|
||||
std::string route_connect_url(const RouteIdentifier &route_id);
|
||||
std::string route_google_maps_url(const GpsTrace &trace);
|
||||
750
tools/jotpluggler/custom_series.cc
Normal file
750
tools/jotpluggler/custom_series.cc
Normal file
@@ -0,0 +1,750 @@
|
||||
#include "tools/jotpluggler/app.h"
|
||||
#include "tools/jotpluggler/common.h"
|
||||
|
||||
#include "implot.h"
|
||||
|
||||
#include <cfloat>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/json11/json11.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
struct PythonEvalResult {
|
||||
std::vector<double> xs;
|
||||
std::vector<double> ys;
|
||||
};
|
||||
|
||||
struct CustomSeriesTemplate {
|
||||
const char *name;
|
||||
const char *globals_code;
|
||||
const char *function_code;
|
||||
const char *preview_text;
|
||||
int required_additional_sources;
|
||||
const char *requirement_text;
|
||||
};
|
||||
|
||||
void write_binary_vector(const fs::path &path, const std::vector<double> &values) {
|
||||
write_file_or_throw(path, values.data(), values.size() * sizeof(double));
|
||||
}
|
||||
|
||||
std::vector<double> read_binary_vector(const fs::path &path) {
|
||||
const std::string raw = read_file_or_throw(path);
|
||||
if (raw.size() % sizeof(double) != 0) {
|
||||
throw std::runtime_error("Invalid binary series file: " + path.string());
|
||||
}
|
||||
std::vector<double> values(raw.size() / sizeof(double));
|
||||
if (!values.empty()) {
|
||||
std::memcpy(values.data(), raw.data(), raw.size());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
void write_text_file(const fs::path &path, std::string_view text) {
|
||||
write_file_or_throw(path, text);
|
||||
}
|
||||
|
||||
fs::path create_custom_series_temp_dir() {
|
||||
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp));
|
||||
fs::create_directories(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void reset_custom_series_editor(CustomSeriesEditorState *editor) {
|
||||
*editor = CustomSeriesEditorState{};
|
||||
}
|
||||
|
||||
bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) {
|
||||
if (path.empty() || path == editor->linked_source) return false;
|
||||
if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) {
|
||||
return false;
|
||||
}
|
||||
editor->additional_sources.push_back(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string next_custom_curve_name(const Pane &pane) {
|
||||
std::set<std::string> 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<int>(tab->panes.size())) {
|
||||
return false;
|
||||
}
|
||||
Pane &pane = tab->panes[static_cast<size_t>(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<std::string> collect_custom_series_paths(const CustomPythonSeries &spec,
|
||||
std::string_view globals_code,
|
||||
std::string_view function_code) {
|
||||
std::set<std::string> 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<std::string> referenced_paths =
|
||||
collect_custom_series_paths(spec, spec.globals_code, spec.function_code);
|
||||
if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code.");
|
||||
|
||||
const fs::path temp_dir = create_custom_series_temp_dir();
|
||||
try {
|
||||
const fs::path globals_path = temp_dir / "globals.py";
|
||||
const fs::path code_path = temp_dir / "code.py";
|
||||
const fs::path manifest_path = temp_dir / "manifest.json";
|
||||
const fs::path out_t_path = temp_dir / "result.t.bin";
|
||||
const fs::path out_v_path = temp_dir / "result.v.bin";
|
||||
|
||||
write_text_file(globals_path, spec.globals_code);
|
||||
write_text_file(code_path, spec.function_code);
|
||||
|
||||
json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end());
|
||||
json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end());
|
||||
json11::Json::array series_json;
|
||||
size_t series_index = 0;
|
||||
for (const std::string &path : referenced_paths) {
|
||||
const RouteSeries *series = app_find_route_series(session, path);
|
||||
if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) {
|
||||
throw std::runtime_error("Missing route series " + path);
|
||||
}
|
||||
const std::string prefix = "series_" + std::to_string(series_index++);
|
||||
const fs::path time_path = temp_dir / (prefix + ".t.bin");
|
||||
const fs::path value_path = temp_dir / (prefix + ".v.bin");
|
||||
write_binary_vector(time_path, series->times);
|
||||
write_binary_vector(value_path, series->values);
|
||||
series_json.push_back(json11::Json::object{
|
||||
{"path", path}, {"t", time_path.string()}, {"v", value_path.string()}});
|
||||
}
|
||||
const json11::Json manifest_json = json11::Json::object{
|
||||
{"paths", std::move(paths_json)},
|
||||
{"linked_source", spec.linked_source},
|
||||
{"additional_sources", std::move(additional_json)},
|
||||
{"series", std::move(series_json)},
|
||||
};
|
||||
write_text_file(manifest_path, manifest_json.dump());
|
||||
|
||||
const CommandResult process = run_process_capture_output({
|
||||
"python3",
|
||||
(repo_root() / "tools" / "jotpluggler" / "math_eval.py").string(),
|
||||
manifest_path.string(),
|
||||
globals_path.string(),
|
||||
code_path.string(),
|
||||
out_t_path.string(),
|
||||
out_v_path.string(),
|
||||
});
|
||||
if (process.exit_code != 0) {
|
||||
const std::string error_text = util::strip(process.output);
|
||||
throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text);
|
||||
}
|
||||
|
||||
PythonEvalResult result;
|
||||
result.xs = read_binary_vector(out_t_path);
|
||||
result.ys = read_binary_vector(out_v_path);
|
||||
if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) {
|
||||
throw std::runtime_error("Custom series returned invalid output");
|
||||
}
|
||||
fs::remove_all(temp_dir);
|
||||
return result;
|
||||
} catch (...) {
|
||||
std::error_code ignore_error;
|
||||
fs::remove_all(temp_dir, ignore_error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) {
|
||||
if (!curve->custom_python.has_value()) {
|
||||
return;
|
||||
}
|
||||
if (!session->route_data.has_time_range || session->route_data.series.empty()) {
|
||||
curve->runtime_error_message.clear();
|
||||
curve->xs.clear();
|
||||
curve->ys.clear();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python);
|
||||
curve->runtime_error_message.clear();
|
||||
curve->xs = std::move(result.xs);
|
||||
curve->ys = std::move(result.ys);
|
||||
} catch (const std::exception &err) {
|
||||
curve->xs.clear();
|
||||
curve->ys.clear();
|
||||
const std::string err_text = err.what();
|
||||
if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) {
|
||||
curve->runtime_error_message = err_text;
|
||||
return;
|
||||
}
|
||||
const std::string error_message = std::string("Failed to evaluate custom series \"")
|
||||
+ app_curve_display_name(*curve) + "\":\n\n" + err_text;
|
||||
if (curve->runtime_error_message != error_message) {
|
||||
curve->runtime_error_message = error_message;
|
||||
state->error_text = error_message;
|
||||
state->open_error_popup = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::array<CustomSeriesTemplate, 4> &custom_series_templates() {
|
||||
static constexpr std::array<CustomSeriesTemplate, 4> 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<double> preview_xs;
|
||||
std::vector<double> 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<double> plot_xs;
|
||||
std::vector<double> 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<int>(plot_xs.size()), spec);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
} else {
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133));
|
||||
ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result.");
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
}
|
||||
|
||||
std::string custom_series_name_status(const Pane &pane, std::string_view name) {
|
||||
const std::string trimmed = util::strip(std::string(name));
|
||||
if (trimmed.empty()) return "name required";
|
||||
if (!trimmed.empty() && trimmed.front() == '/') {
|
||||
return "cannot start with /";
|
||||
}
|
||||
for (const Curve &curve : pane.curves) {
|
||||
if (curve.runtime_only && curve.name == trimmed) return "updates existing curve";
|
||||
}
|
||||
return "new curve";
|
||||
}
|
||||
|
||||
const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) {
|
||||
const auto &templates = custom_series_templates();
|
||||
return templates[static_cast<size_t>(std::clamp(editor.selected_template, 0, static_cast<int>(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<int>(editor.additional_sources.size()) >= templ.required_additional_sources;
|
||||
}
|
||||
|
||||
bool prepare_custom_series_spec(CustomSeriesEditorState *editor,
|
||||
UiState *state,
|
||||
bool require_name,
|
||||
CustomPythonSeries *out_spec) {
|
||||
editor->name = util::strip(editor->name);
|
||||
editor->linked_source = util::strip(editor->linked_source);
|
||||
for (std::string &path : editor->additional_sources) {
|
||||
path = util::strip(path);
|
||||
}
|
||||
editor->additional_sources.erase(
|
||||
std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(),
|
||||
[&](const std::string &path) { return path.empty() || path == editor->linked_source; }),
|
||||
editor->additional_sources.end());
|
||||
|
||||
if (require_name && editor->name.empty()) {
|
||||
state->error_text = "Custom series name is required.";
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
if (require_name && !editor->name.empty() && editor->name.front() == '/') {
|
||||
state->error_text = "Custom series names may not start with '/'.";
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
*out_spec = CustomPythonSeries{
|
||||
.linked_source = editor->linked_source,
|
||||
.additional_sources = editor->additional_sources,
|
||||
.globals_code = editor->globals_code,
|
||||
.function_code = editor->function_code,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
bool preview_custom_series_editor(AppSession *session, UiState *state) {
|
||||
CustomSeriesEditorState &editor = state->custom_series;
|
||||
const CustomSeriesTemplate &templ = selected_custom_series_template(editor);
|
||||
if (editor.linked_source.empty()) {
|
||||
state->error_text = "Choose an input timeseries before previewing.";
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Custom series preview failed";
|
||||
return false;
|
||||
}
|
||||
if (static_cast<int>(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<int>(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<size_t>(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<const char *>(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<int>(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<int>(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<int>(editor.additional_sources.size())) {
|
||||
editor.additional_sources.erase(editor.additional_sources.begin()
|
||||
+ static_cast<std::ptrdiff_t>(editor.selected_additional_source));
|
||||
editor.selected_additional_source = editor.additional_sources.empty()
|
||||
? -1 : std::clamp(editor.selected_additional_source, 0, static_cast<int>(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<int>(i),
|
||||
ImGuiSelectableFlags_SpanAllColumns)) {
|
||||
editor.selected_additional_source = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
if (ImGui::BeginDragDropTarget()) {
|
||||
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) {
|
||||
if (add_additional_source(&editor, static_cast<const char *>(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<int>(i),
|
||||
ImGuiSelectableFlags_AllowDoubleClick)) {
|
||||
editor.selected_template = static_cast<int>(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<int>(tab->panes.size()))
|
||||
? &tab->panes[static_cast<size_t>(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();
|
||||
}
|
||||
}
|
||||
400
tools/jotpluggler/dbc.h
Normal file
400
tools/jotpluggler/dbc.h
Normal file
@@ -0,0 +1,400 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/util.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <regex>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<ValueDescriptionEntry> value_descriptions;
|
||||
};
|
||||
|
||||
struct Message {
|
||||
uint32_t address = 0;
|
||||
std::string name;
|
||||
uint32_t size = 0;
|
||||
std::string comment;
|
||||
std::string transmitter;
|
||||
std::vector<Signal> signals;
|
||||
int multiplexor_index = -1;
|
||||
|
||||
const std::vector<Signal> &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 = "<dbc>");
|
||||
|
||||
const Message *message(uint32_t address) const;
|
||||
const std::unordered_map<uint32_t, Message> &messages() const { return messages_; }
|
||||
std::vector<std::string> 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<uint32_t, Message> messages_;
|
||||
};
|
||||
|
||||
void updateMsbLsb(Signal *signal);
|
||||
double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size);
|
||||
std::optional<double> signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size);
|
||||
|
||||
namespace {
|
||||
|
||||
std::string unescape_dbc_string(std::string text) {
|
||||
size_t pos = 0;
|
||||
while ((pos = text.find("\\\"", pos)) != std::string::npos) {
|
||||
text.replace(pos, 2, "\"");
|
||||
++pos;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
int flip_bit_pos(int start_bit) {
|
||||
return 8 * (start_bit / 8) + 7 - start_bit % 8;
|
||||
}
|
||||
|
||||
std::string read_multiline_statement(std::istream &stream, std::string statement, int *line_number) {
|
||||
static const std::regex statement_end(R"(\"\s*;\s*$)");
|
||||
while (true) {
|
||||
const std::string trimmed = util::strip(statement);
|
||||
if (std::regex_search(trimmed, statement_end)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
std::string next_line;
|
||||
if (!std::getline(stream, next_line)) {
|
||||
return trimmed;
|
||||
}
|
||||
statement += "\n";
|
||||
statement += next_line;
|
||||
++(*line_number);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
inline void updateMsbLsb(Signal *signal) {
|
||||
if (signal->is_little_endian) {
|
||||
signal->lsb = signal->start_bit;
|
||||
signal->msb = signal->start_bit + signal->size - 1;
|
||||
} else {
|
||||
signal->lsb = flip_bit_pos(flip_bit_pos(signal->start_bit) + signal->size - 1);
|
||||
signal->msb = signal->start_bit;
|
||||
}
|
||||
}
|
||||
|
||||
inline double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size) {
|
||||
const int msb_byte = signal.msb / 8;
|
||||
if (msb_byte >= static_cast<int>(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<int>(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<int64_t>(val) * signal.factor + signal.offset;
|
||||
}
|
||||
|
||||
[[noreturn]] inline void parse_error(const std::string &filename, int line_number, const std::string &message, const std::string &line) {
|
||||
std::ostringstream out;
|
||||
out << "[" << filename << ":" << line_number << "] " << message << ": " << line;
|
||||
throw std::runtime_error(out.str());
|
||||
}
|
||||
|
||||
inline Database::Database(const std::filesystem::path &path) {
|
||||
const std::string content = util::read_file(path.string());
|
||||
if (content.empty() && !std::filesystem::exists(path)) {
|
||||
throw std::runtime_error("Failed to open DBC " + path.string());
|
||||
}
|
||||
parse(content, path.filename().string());
|
||||
}
|
||||
|
||||
inline Database Database::fromContent(const std::string &content, const std::string &filename) {
|
||||
Database db;
|
||||
db.parse(content, filename);
|
||||
return db;
|
||||
}
|
||||
|
||||
inline const Message *Database::message(uint32_t address) const {
|
||||
auto it = messages_.find(address);
|
||||
return it == messages_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
inline std::vector<std::string> 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<int>(rounded));
|
||||
}
|
||||
if (max_index < 0) return {};
|
||||
std::vector<std::string> names(static_cast<size_t>(max_index + 1));
|
||||
for (const auto &entry : signal.value_descriptions) {
|
||||
names[static_cast<size_t>(std::llround(entry.value))] = entry.text;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
inline void Database::parse(const std::string &content, const std::string &filename) {
|
||||
filename_ = filename;
|
||||
messages_.clear();
|
||||
std::istringstream stream(content);
|
||||
std::string raw_line;
|
||||
Message *current_message = nullptr;
|
||||
int line_number = 0;
|
||||
while (std::getline(stream, raw_line)) {
|
||||
++line_number;
|
||||
std::string line = util::strip(raw_line);
|
||||
if (line.empty()) continue;
|
||||
if (util::starts_with(line, "BO_ ")) {
|
||||
parseBo(line, line_number, ¤t_message);
|
||||
} else if (util::starts_with(line, "SG_ ")) {
|
||||
if (current_message == nullptr) {
|
||||
parse_error(filename, line_number, "Signal without current message", line);
|
||||
}
|
||||
parseSg(line, line_number, current_message);
|
||||
} else if (util::starts_with(line, "VAL_ ")) {
|
||||
parseVal(line, line_number);
|
||||
} else if (util::starts_with(line, "CM_ BO_")) {
|
||||
parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number);
|
||||
} else if (util::starts_with(line, "CM_ SG_")) {
|
||||
parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number);
|
||||
}
|
||||
}
|
||||
finalize();
|
||||
}
|
||||
|
||||
inline void Database::parseBo(const std::string &line, int line_number, Message **current_message) {
|
||||
static const std::regex pattern(R"(^BO_\s+(\w+)\s+(\w+)\s*:\s*(\w+)\s+(\w+)\s*$)");
|
||||
std::smatch match;
|
||||
if (!std::regex_match(line, match, pattern)) {
|
||||
parse_error("<dbc>", line_number, "Invalid BO_ line format", line);
|
||||
}
|
||||
uint32_t address = static_cast<uint32_t>(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<uint32_t>(std::stoul(match[3].str(), nullptr, 0));
|
||||
message.transmitter = match[4].str();
|
||||
message.signals.clear();
|
||||
message.multiplexor_index = -1;
|
||||
*current_message = &message;
|
||||
}
|
||||
|
||||
inline void Database::parseSg(const std::string &line, int line_number, Message *current_message) {
|
||||
static const std::regex multiplex_pattern(R"(^SG_\s+(\w+)\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
|
||||
static const std::regex normal_pattern(R"(^SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
|
||||
|
||||
std::smatch match;
|
||||
Signal signal;
|
||||
int offset = 0;
|
||||
if (std::regex_match(line, match, normal_pattern)) {
|
||||
offset = 0;
|
||||
} else if (std::regex_match(line, match, multiplex_pattern)) {
|
||||
offset = 1;
|
||||
const std::string indicator = match[2].str();
|
||||
if (indicator == "M") {
|
||||
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [](const Signal &existing) {
|
||||
return existing.type == Signal::Type::Multiplexor;
|
||||
})) {
|
||||
parse_error(filename_, line_number, "Multiple multiplexor", line);
|
||||
}
|
||||
signal.type = Signal::Type::Multiplexor;
|
||||
} else if (!indicator.empty() && indicator.front() == 'm') {
|
||||
signal.type = Signal::Type::Multiplexed;
|
||||
signal.multiplex_value = std::stoi(indicator.substr(1));
|
||||
} else {
|
||||
parse_error("<dbc>", line_number, "Invalid multiplex indicator", line);
|
||||
}
|
||||
} else {
|
||||
parse_error("<dbc>", line_number, "Invalid SG_ line format", line);
|
||||
}
|
||||
|
||||
signal.name = match[1].str();
|
||||
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [&](const Signal &existing) {
|
||||
return existing.name == signal.name;
|
||||
})) {
|
||||
parse_error(filename_, line_number, "Duplicate signal name", line);
|
||||
}
|
||||
signal.start_bit = std::stoi(match[2 + offset].str());
|
||||
signal.size = std::stoi(match[3 + offset].str());
|
||||
signal.is_little_endian = match[4 + offset].str() == "1";
|
||||
signal.is_signed = match[5 + offset].str() == "-";
|
||||
signal.factor = std::stod(match[6 + offset].str());
|
||||
signal.offset = std::stod(match[7 + offset].str());
|
||||
signal.min = std::stod(match[8 + offset].str());
|
||||
signal.max = std::stod(match[9 + offset].str());
|
||||
signal.unit = match[10 + offset].str();
|
||||
signal.receiver_name = util::strip(match[11 + offset].str());
|
||||
updateMsbLsb(&signal);
|
||||
current_message->signals.push_back(std::move(signal));
|
||||
}
|
||||
|
||||
inline void Database::parseVal(const std::string &line, int line_number) {
|
||||
static const std::regex prefix(R"(^VAL_\s+(\w+)\s+(\w+)\s+(.*);$)");
|
||||
std::smatch match;
|
||||
if (!std::regex_match(line, match, prefix)) {
|
||||
parse_error("<dbc>", line_number, "Invalid VAL_ line format", line);
|
||||
}
|
||||
|
||||
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
|
||||
auto msg_it = messages_.find(address);
|
||||
if (msg_it == messages_.end()) {
|
||||
return;
|
||||
}
|
||||
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
|
||||
return signal.name == match[2].str();
|
||||
});
|
||||
if (sig_it == msg_it->second.signals.end()) {
|
||||
return;
|
||||
}
|
||||
|
||||
static const std::regex entry_pattern(R"(([+-]?\d+(?:\.\d+)?)\s+\"((?:[^\"\\]|\\.)*)\")");
|
||||
const std::string defs = match[3].str();
|
||||
for (std::sregex_iterator it(defs.begin(), defs.end(), entry_pattern), end; it != end; ++it) {
|
||||
sig_it->value_descriptions.push_back(ValueDescriptionEntry{
|
||||
.value = std::stod((*it)[1].str()),
|
||||
.text = (*it)[2].str(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
inline void Database::parseCmBo(const std::string &line, int line_number) {
|
||||
static const std::regex pattern(R"(^CM_\s+BO_\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
|
||||
std::smatch match;
|
||||
if (!std::regex_match(line, match, pattern)) {
|
||||
parse_error(filename_, line_number, "Invalid message comment format", line);
|
||||
}
|
||||
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
|
||||
auto it = messages_.find(address);
|
||||
if (it != messages_.end()) {
|
||||
it->second.comment = unescape_dbc_string(match[2].str());
|
||||
}
|
||||
}
|
||||
|
||||
inline void Database::parseCmSg(const std::string &line, int line_number) {
|
||||
static const std::regex pattern(R"(^CM_\s+SG_\s*(\w+)\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
|
||||
std::smatch match;
|
||||
if (!std::regex_match(line, match, pattern)) {
|
||||
parse_error(filename_, line_number, "Invalid signal comment format", line);
|
||||
}
|
||||
|
||||
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
|
||||
auto msg_it = messages_.find(address);
|
||||
if (msg_it == messages_.end()) return;
|
||||
|
||||
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
|
||||
return signal.name == match[2].str();
|
||||
});
|
||||
if (sig_it != msg_it->second.signals.end()) {
|
||||
sig_it->comment = unescape_dbc_string(match[3].str());
|
||||
}
|
||||
}
|
||||
|
||||
inline void Database::finalize() {
|
||||
for (auto &[_, message] : messages_) {
|
||||
std::sort(message.signals.begin(), message.signals.end(), [](const Signal &left, const Signal &right) {
|
||||
return std::tie(right.type, left.multiplex_value, left.start_bit, left.name)
|
||||
< std::tie(left.type, right.multiplex_value, right.start_bit, right.name);
|
||||
});
|
||||
message.multiplexor_index = -1;
|
||||
for (size_t i = 0; i < message.signals.size(); ++i) {
|
||||
if (message.signals[i].type == Signal::Type::Multiplexor) {
|
||||
message.multiplexor_index = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (Signal &signal : message.signals) {
|
||||
signal.multiplexor_index = signal.type == Signal::Type::Multiplexed ? message.multiplexor_index : -1;
|
||||
if (signal.type == Signal::Type::Multiplexed && signal.multiplexor_index < 0) {
|
||||
signal.type = Signal::Type::Normal;
|
||||
signal.multiplex_value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline std::optional<double> 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<size_t>(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
|
||||
2
tools/jotpluggler/generated_dbcs/.gitignore
vendored
Normal file
2
tools/jotpluggler/generated_dbcs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
24
tools/jotpluggler/icons.cc
Normal file
24
tools/jotpluggler/icons.cc
Normal file
@@ -0,0 +1,24 @@
|
||||
#include "tools/jotpluggler/app.h"
|
||||
#include "tools/jotpluggler/common.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
void icon_add_font(float size, bool merge, const ImFont *base_font) {
|
||||
const std::filesystem::path ttf = repo_root() / "third_party" / "bootstrap" / "bootstrap-icons.ttf";
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
ImFontConfig config;
|
||||
config.MergeMode = merge;
|
||||
config.GlyphMinAdvanceX = size;
|
||||
if (base_font != nullptr) {
|
||||
ImFontBaked *baked = const_cast<ImFont *>(base_font)->GetFontBaked(size);
|
||||
const float base_center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f;
|
||||
config.GlyphOffset.y = std::round(size * 0.5f - base_center);
|
||||
}
|
||||
static const ImWchar ranges[] = {0xF000, 0xF8FF, 0};
|
||||
io.Fonts->AddFontFromFileTTF(ttf.c_str(), size, &config, ranges);
|
||||
}
|
||||
|
||||
bool icon_menu_item(const char *glyph, const char *label, const char *shortcut, bool selected, bool enabled) {
|
||||
assert(glyph != nullptr && glyph[0] != '\0');
|
||||
return ImGui::MenuItem(util::string_format("%s %s", glyph, label).c_str(), shortcut, selected, enabled);
|
||||
}
|
||||
166
tools/jotpluggler/internal.h
Normal file
166
tools/jotpluggler/internal.h
Normal file
@@ -0,0 +1,166 @@
|
||||
#pragma once
|
||||
|
||||
#include "tools/jotpluggler/common.h"
|
||||
#include "tools/jotpluggler/map.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
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<std::string> browser_paths;
|
||||
std::string special_item_id;
|
||||
PaneCurveDragPayload curve_ref;
|
||||
};
|
||||
|
||||
inline constexpr float SIDEBAR_WIDTH = 320.0f;
|
||||
inline constexpr float SIDEBAR_MIN_WIDTH = 220.0f;
|
||||
inline constexpr float SIDEBAR_MAX_WIDTH = 520.0f;
|
||||
inline constexpr float TIMELINE_BAR_HEIGHT = 14.0f;
|
||||
inline constexpr float STATUS_BAR_HEIGHT = 52.0f;
|
||||
inline constexpr double MIN_HORIZONTAL_ZOOM_SECONDS = 2.0;
|
||||
|
||||
struct UiMetrics {
|
||||
float width = 0.0f;
|
||||
float height = 0.0f;
|
||||
float top_offset = 0.0f;
|
||||
float sidebar_width = SIDEBAR_WIDTH;
|
||||
float content_x = 0.0f;
|
||||
float content_y = 0.0f;
|
||||
float content_w = 0.0f;
|
||||
float content_h = 0.0f;
|
||||
float status_bar_y = 0.0f;
|
||||
};
|
||||
|
||||
std::filesystem::path resolve_layout_path(const std::string &layout_arg);
|
||||
std::filesystem::path autosave_path_for_layout(const std::filesystem::path &layout_path);
|
||||
std::vector<std::string> available_layout_names();
|
||||
|
||||
SketchLayout make_empty_layout();
|
||||
void cancel_rename_tab(UiState *state);
|
||||
void sync_ui_state(UiState *state, const SketchLayout &layout);
|
||||
void sync_route_buffers(UiState *state, const AppSession &session);
|
||||
void sync_stream_buffers(UiState *state, const AppSession &session);
|
||||
void sync_layout_buffers(UiState *state, const AppSession &session);
|
||||
void mark_all_docks_dirty(UiState *state);
|
||||
void clear_layout_autosave(const AppSession &session);
|
||||
bool autosave_layout(AppSession *session, UiState *state);
|
||||
bool apply_axis_limits_editor(AppSession *session, UiState *state);
|
||||
void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index);
|
||||
void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state);
|
||||
void clear_pane_vertical_limits(Pane *pane);
|
||||
|
||||
void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks);
|
||||
void start_new_layout(AppSession *session, UiState *state, const std::string &status_text = "New untitled layout");
|
||||
void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override);
|
||||
|
||||
void app_push_bold_font();
|
||||
void app_pop_bold_font();
|
||||
void draw_vertical_splitter(const char *id, float height, float min_left, float max_left, float *left_width);
|
||||
void draw_right_splitter(const char *id, float height, float min_right, float max_right, float *right_width);
|
||||
bool draw_horizontal_splitter(const char *id, float width, float min_top, float max_top, float *top_height);
|
||||
void draw_payload_bytes(std::string_view data, const std::string *prev_data = nullptr);
|
||||
void draw_payload_preview_boxes(const char *id, std::string_view data, const std::string *prev_data, float max_width);
|
||||
void draw_signal_sparkline(const AppSession &session,
|
||||
const UiState &state,
|
||||
std::string_view signal_path,
|
||||
bool selected,
|
||||
ImVec2 size = ImVec2(0.0f, 24.0f));
|
||||
ImU32 mix_color(ImU32 a, ImU32 b, float t);
|
||||
void draw_empty_panel(const char *title, const char *message);
|
||||
|
||||
UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width);
|
||||
void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed);
|
||||
void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state);
|
||||
void draw_pane_windows(AppSession *session, UiState *state);
|
||||
|
||||
// plot.cc
|
||||
void draw_plot(const AppSession &session, Pane *pane, UiState *state);
|
||||
bool draw_pane_close_button_overlay();
|
||||
void draw_pane_frame_overlay();
|
||||
std::optional<PaneMenuAction> draw_pane_context_menu(const WorkspaceTab &tab, int pane_index);
|
||||
bool curve_has_samples(const AppSession &session, const Curve &curve);
|
||||
bool curve_has_local_samples(const Curve &curve);
|
||||
std::string app_curve_display_name(const Curve &curve);
|
||||
bool mark_layout_dirty(AppSession *session, UiState *state);
|
||||
|
||||
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path);
|
||||
void sync_camera_feeds(AppSession *session);
|
||||
void apply_route_data(AppSession *session, UiState *state, RouteData route_data);
|
||||
bool apply_undo(AppSession *session, UiState *state);
|
||||
bool apply_redo(AppSession *session, UiState *state);
|
||||
bool infer_stream_follow_state(const UiState &state, const AppSession &session);
|
||||
void ensure_shared_range(UiState *state, const AppSession &session);
|
||||
void clamp_shared_range(UiState *state, const AppSession &session);
|
||||
void reset_shared_range(UiState *state, const AppSession &session);
|
||||
void update_follow_range(UiState *state, const AppSession &session);
|
||||
void advance_playback(UiState *state, const AppSession &session);
|
||||
void step_tracker(UiState *state, double direction);
|
||||
std::string dbc_combo_label(const AppSession &session);
|
||||
const char *log_selector_name(LogSelector selector);
|
||||
const char *log_selector_description(LogSelector selector);
|
||||
std::string format_cache_bytes(uint64_t bytes);
|
||||
MapCacheStats directory_cache_stats(const std::filesystem::path &root);
|
||||
float draw_main_menu_bar(AppSession *session, UiState *state);
|
||||
|
||||
bool reset_layout(AppSession *session, UiState *state);
|
||||
bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg);
|
||||
bool save_layout(AppSession *session, UiState *state, const std::string &layout_path);
|
||||
void rebuild_session_route_data(AppSession *session, UiState *state,
|
||||
const RouteLoadProgressCallback &progress = {});
|
||||
void stop_stream_session(AppSession *session, UiState *state, bool preserve_data = true);
|
||||
bool start_stream_session(AppSession *session,
|
||||
UiState *state,
|
||||
const StreamSourceConfig &source,
|
||||
double buffer_seconds,
|
||||
bool preserve_existing_data = false);
|
||||
void start_async_route_load(AppSession *session, UiState *state);
|
||||
void poll_async_route_load(AppSession *session, UiState *state);
|
||||
bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir);
|
||||
void draw_popups(AppSession *session, UiState *state);
|
||||
|
||||
void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state);
|
||||
void draw_sidebar_resizer(const UiMetrics &ui, UiState *state);
|
||||
|
||||
void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch);
|
||||
|
||||
void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const std::filesystem::path *capture_path);
|
||||
704
tools/jotpluggler/layout.cc
Normal file
704
tools/jotpluggler/layout.cc
Normal file
@@ -0,0 +1,704 @@
|
||||
#include "tools/jotpluggler/internal.h"
|
||||
#include "system/hardware/hw.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
enum class ModalAction {
|
||||
None,
|
||||
Primary,
|
||||
Secondary,
|
||||
};
|
||||
|
||||
struct FindSignalMatch {
|
||||
const std::string *path = nullptr;
|
||||
int score = 0;
|
||||
};
|
||||
|
||||
struct DbcEditorSource {
|
||||
fs::path path;
|
||||
DbcEditorState::SourceKind kind = DbcEditorState::SourceKind::None;
|
||||
};
|
||||
|
||||
StreamSourceConfig stream_source_config_from_ui(const UiState &state) {
|
||||
StreamSourceConfig source;
|
||||
source.kind = state.stream_source_kind;
|
||||
source.address = util::strip(state.stream_address_buffer);
|
||||
if (source.kind == StreamSourceKind::CerealLocal) {
|
||||
source.address = "127.0.0.1";
|
||||
} else {
|
||||
source.address = normalize_stream_address(std::move(source.address));
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
void open_queued_popup(bool &flag, const char *name) {
|
||||
if (flag) {
|
||||
ImGui::OpenPopup(name);
|
||||
flag = false;
|
||||
}
|
||||
}
|
||||
|
||||
ModalAction draw_modal_action_row(const char *primary_label,
|
||||
const char *secondary_label = "Cancel",
|
||||
float width = 120.0f) {
|
||||
if (ImGui::Button(primary_label, ImVec2(width, 0.0f))) {
|
||||
return ModalAction::Primary;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(secondary_label, ImVec2(width, 0.0f))) {
|
||||
return ModalAction::Secondary;
|
||||
}
|
||||
return ModalAction::None;
|
||||
}
|
||||
|
||||
std::vector<FindSignalMatch> find_signal_matches(const AppSession &session, std::string_view query) {
|
||||
std::vector<FindSignalMatch> matches;
|
||||
if (query.empty()) {
|
||||
return matches;
|
||||
}
|
||||
const std::string needle = lowercase_copy(query);
|
||||
for (const std::string &path : session.route_data.paths) {
|
||||
const std::string hay = lowercase_copy(path);
|
||||
const size_t pos = hay.find(needle);
|
||||
if (pos == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
const size_t slash = path.find_last_of('/');
|
||||
const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1);
|
||||
int score = static_cast<int>(pos * 8 + path.size());
|
||||
if (lowercase_copy(label) == needle) score -= 60;
|
||||
if (util::starts_with(hay, needle)) score -= 30;
|
||||
matches.push_back({.path = &path, .score = score});
|
||||
}
|
||||
std::sort(matches.begin(), matches.end(), [](const FindSignalMatch &a, const FindSignalMatch &b) {
|
||||
return std::tie(a.score, *a.path) < std::tie(b.score, *b.path);
|
||||
});
|
||||
if (matches.size() > 200) {
|
||||
matches.resize(200);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
bool open_find_signal_result(UiState *state, const std::string &path) {
|
||||
state->selected_browser_paths = {path};
|
||||
state->selected_browser_path = path;
|
||||
state->browser_selection_anchor = path;
|
||||
state->status_text = "Selected signal " + path;
|
||||
return true;
|
||||
}
|
||||
|
||||
void draw_open_route_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Open Route", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TextUnformatted("Load a route into the current layout.");
|
||||
ImGui::Separator();
|
||||
input_text_string("Route", &state->route_buffer);
|
||||
input_text_string("Data Dir", &state->data_dir_buffer);
|
||||
ImGui::Spacing();
|
||||
switch (draw_modal_action_row("Load")) {
|
||||
case ModalAction::Primary:
|
||||
reload_session(session, state, state->route_buffer, state->data_dir_buffer);
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::Secondary:
|
||||
sync_route_buffers(state, *session);
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::None:
|
||||
break;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_stream_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Live Stream", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("Connect to a live source.");
|
||||
ImGui::Separator();
|
||||
if (ImGui::RadioButton("Local (MSGQ)", state->stream_source_kind == StreamSourceKind::CerealLocal)) {
|
||||
state->stream_source_kind = StreamSourceKind::CerealLocal;
|
||||
}
|
||||
if (ImGui::RadioButton("Remote (ZMQ)", state->stream_source_kind == StreamSourceKind::CerealRemote)) {
|
||||
state->stream_source_kind = StreamSourceKind::CerealRemote;
|
||||
}
|
||||
|
||||
if (state->stream_source_kind == StreamSourceKind::CerealRemote) {
|
||||
input_text_string("Address", &state->stream_address_buffer);
|
||||
}
|
||||
ImGui::InputDouble("Buffer (seconds)", &state->stream_buffer_seconds, 0.0, 0.0, "%.0f");
|
||||
ImGui::Spacing();
|
||||
switch (draw_modal_action_row("Connect")) {
|
||||
case ModalAction::Primary: {
|
||||
const StreamSourceConfig source = stream_source_config_from_ui(*state);
|
||||
if (start_stream_session(session, state, source, state->stream_buffer_seconds, false)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ModalAction::Secondary:
|
||||
sync_stream_buffers(state, *session);
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::None:
|
||||
break;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_load_layout_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Load Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TextUnformatted("Load a JotPlugger JSON layout.");
|
||||
ImGui::Separator();
|
||||
input_text_string("Layout", &state->load_layout_buffer);
|
||||
ImGui::Spacing();
|
||||
switch (draw_modal_action_row("Load")) {
|
||||
case ModalAction::Primary:
|
||||
if (reload_layout(session, state, state->load_layout_buffer)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
break;
|
||||
case ModalAction::Secondary:
|
||||
sync_layout_buffers(state, *session);
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::None:
|
||||
break;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_save_layout_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Save Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TextUnformatted("Save the current workspace as a JotPlugger JSON layout.");
|
||||
ImGui::Separator();
|
||||
input_text_string("Layout", &state->save_layout_buffer);
|
||||
ImGui::Spacing();
|
||||
switch (draw_modal_action_row("Save")) {
|
||||
case ModalAction::Primary:
|
||||
if (save_layout(session, state, state->save_layout_buffer)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
break;
|
||||
case ModalAction::Secondary:
|
||||
sync_layout_buffers(state, *session);
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::None:
|
||||
break;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_preferences_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Preferences", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
if (session->map_data) {
|
||||
const MapCacheStats map_cache = session->map_data->cacheStats();
|
||||
const MapCacheStats download_cache = directory_cache_stats(Path::download_cache_root());
|
||||
ImGui::TextUnformatted("Map");
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Map cache: %s in %zu file%s",
|
||||
format_cache_bytes(map_cache.bytes).c_str(),
|
||||
map_cache.files,
|
||||
map_cache.files == 1 ? "" : "s");
|
||||
if (ImGui::Button("Clear Map Cache", ImVec2(120.0f, 0.0f))) {
|
||||
session->map_data->clearCache();
|
||||
state->status_text = "Cleared map cache";
|
||||
}
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted("comma Download Cache");
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Download cache: %s in %zu file%s",
|
||||
format_cache_bytes(download_cache.bytes).c_str(),
|
||||
download_cache.files,
|
||||
download_cache.files == 1 ? "" : "s");
|
||||
ImGui::TextDisabled("%s", Path::download_cache_root().c_str());
|
||||
ImGui::Spacing();
|
||||
}
|
||||
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_find_signal_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Find Signal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TextUnformatted("Search decoded signals across the loaded route.");
|
||||
ImGui::Separator();
|
||||
ImGui::SetNextItemWidth(560.0f);
|
||||
input_text_with_hint_string("##find_signal_query", "Search signal path or name...", &state->find_signal_buffer);
|
||||
if (ImGui::IsWindowAppearing()) {
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
const std::vector<FindSignalMatch> matches = find_signal_matches(*session, state->find_signal_buffer);
|
||||
ImGui::Spacing();
|
||||
ImGui::TextDisabled("%zu match%s", matches.size(), matches.size() == 1 ? "" : "es");
|
||||
if (ImGui::BeginChild("##find_signal_results", ImVec2(760.0f, 360.0f), true)) {
|
||||
for (const FindSignalMatch &match : matches) {
|
||||
const std::string &path = *match.path;
|
||||
const size_t slash = path.find_last_of('/');
|
||||
const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1);
|
||||
if (ImGui::Selectable((std::string(label) + "##" + path).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) {
|
||||
if (open_find_signal_result(state, path)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
}
|
||||
ImGui::SameLine(280.0f);
|
||||
ImGui::TextDisabled("%s", path.c_str());
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
std::string default_dbc_template() {
|
||||
return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n";
|
||||
}
|
||||
|
||||
DbcEditorSource resolve_dbc_editor_source(const std::string &dbc_name) {
|
||||
const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs";
|
||||
const std::array<DbcEditorSource, 2> candidates = {{
|
||||
{.path = repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Opendbc},
|
||||
{.path = generated_dbc_dir / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Generated},
|
||||
}};
|
||||
for (const DbcEditorSource &candidate : candidates) {
|
||||
if (fs::exists(candidate.path)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void load_dbc_editor_state(const AppSession &session, UiState *state) {
|
||||
DbcEditorState &editor = state->dbc_editor;
|
||||
const std::string dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name;
|
||||
editor.source_name = dbc_name.empty() ? "untitled" : dbc_name;
|
||||
editor.source_path.clear();
|
||||
editor.source_kind = DbcEditorState::SourceKind::None;
|
||||
if (dbc_name.empty()) {
|
||||
editor.save_name = "custom_can";
|
||||
editor.text = default_dbc_template();
|
||||
} else {
|
||||
const DbcEditorSource source = resolve_dbc_editor_source(dbc_name);
|
||||
editor.source_kind = source.kind;
|
||||
editor.source_path = source.path;
|
||||
editor.text = source.path.empty() ? default_dbc_template() : read_file_or_throw(source.path);
|
||||
editor.save_name = source.kind == DbcEditorState::SourceKind::Generated ? dbc_name : dbc_name + "_edited";
|
||||
}
|
||||
editor.loaded = true;
|
||||
}
|
||||
|
||||
bool ensure_dbc_editor_loaded(const AppSession &session, UiState *state) {
|
||||
if (!state->dbc_editor.loaded) {
|
||||
try {
|
||||
load_dbc_editor_state(session, state);
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool save_dbc_editor_contents(AppSession *session, UiState *state) {
|
||||
DbcEditorState &editor = state->dbc_editor;
|
||||
editor.save_name = util::strip(editor.save_name);
|
||||
if (editor.save_name.empty()) {
|
||||
state->error_text = "DBC name cannot be empty";
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
if (editor.source_kind == DbcEditorState::SourceKind::Opendbc && editor.save_name == editor.source_name) {
|
||||
state->error_text = "Save edited opendbc files under a new name";
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
dbc::Database::fromContent(editor.text, editor.save_name + ".dbc");
|
||||
const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs";
|
||||
fs::create_directories(generated_dbc_dir);
|
||||
const fs::path output = generated_dbc_dir / (editor.save_name + ".dbc");
|
||||
write_file_or_throw(output, editor.text);
|
||||
apply_dbc_override_change(session, state, editor.save_name);
|
||||
editor.source_name = editor.save_name;
|
||||
editor.source_path = output;
|
||||
editor.source_kind = DbcEditorState::SourceKind::Generated;
|
||||
editor.loaded = false;
|
||||
state->status_text = "Saved DBC " + editor.save_name;
|
||||
return true;
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void draw_dbc_editor_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("DBC Editor", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
DbcEditorState &editor = state->dbc_editor;
|
||||
if (!ensure_dbc_editor_loaded(*session, state)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
return;
|
||||
}
|
||||
ImGui::TextUnformatted("Edit DBC text and save it into generated_dbcs.");
|
||||
ImGui::Separator();
|
||||
ImGui::SetNextItemWidth(260.0f);
|
||||
input_text_string("DBC Name", &editor.save_name, ImGuiInputTextFlags_AutoSelectAll);
|
||||
if (!editor.source_path.empty()) {
|
||||
ImGui::TextDisabled("%s", editor.source_path.string().c_str());
|
||||
} else {
|
||||
ImGui::TextDisabled("New in-memory DBC");
|
||||
}
|
||||
ImGui::Spacing();
|
||||
input_text_multiline_string("##dbc_editor_text", &editor.text, ImVec2(920.0f, 520.0f), ImGuiInputTextFlags_AllowTabInput);
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) {
|
||||
if (save_dbc_editor_contents(session, state)) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Reload Source", ImVec2(140.0f, 0.0f))) {
|
||||
editor.loaded = false;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
|
||||
editor.loaded = false;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_axis_limits_popup(AppSession *session, UiState *state) {
|
||||
if (!ImGui::BeginPopupModal("Edit Axis Limits", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
const WorkspaceTab *tab = app_active_tab(session->layout, *state);
|
||||
const bool valid_pane = tab != nullptr
|
||||
&& state->axis_limits.pane_index >= 0
|
||||
&& state->axis_limits.pane_index < static_cast<int>(tab->panes.size());
|
||||
if (!valid_pane) {
|
||||
ImGui::TextWrapped("The selected pane is no longer available.");
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
|
||||
state->axis_limits.pane_index = -1;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("X range applies to the active tab. Y limits apply to the selected pane.");
|
||||
ImGui::Separator();
|
||||
ImGui::TextUnformatted("Horizontal");
|
||||
ImGui::SetNextItemWidth(180.0f);
|
||||
ImGui::InputDouble("X Min", &state->axis_limits.x_min, 0.0, 0.0, "%.3f");
|
||||
ImGui::SetNextItemWidth(180.0f);
|
||||
ImGui::InputDouble("X Max", &state->axis_limits.x_max, 0.0, 0.0, "%.3f");
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted("Vertical");
|
||||
ImGui::Checkbox("Use Y Min", &state->axis_limits.y_min_enabled);
|
||||
ImGui::BeginDisabled(!state->axis_limits.y_min_enabled);
|
||||
ImGui::SetNextItemWidth(180.0f);
|
||||
ImGui::InputDouble("Y Min", &state->axis_limits.y_min, 0.0, 0.0, "%.6g");
|
||||
ImGui::EndDisabled();
|
||||
ImGui::Checkbox("Use Y Max", &state->axis_limits.y_max_enabled);
|
||||
ImGui::BeginDisabled(!state->axis_limits.y_max_enabled);
|
||||
ImGui::SetNextItemWidth(180.0f);
|
||||
ImGui::InputDouble("Y Max", &state->axis_limits.y_max, 0.0, 0.0, "%.6g");
|
||||
ImGui::EndDisabled();
|
||||
ImGui::Spacing();
|
||||
switch (draw_modal_action_row("Apply")) {
|
||||
case ModalAction::Primary:
|
||||
if (apply_axis_limits_editor(session, state)) {
|
||||
state->axis_limits.pane_index = -1;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
break;
|
||||
case ModalAction::Secondary:
|
||||
state->axis_limits.pane_index = -1;
|
||||
ImGui::CloseCurrentPopup();
|
||||
break;
|
||||
case ModalAction::None:
|
||||
break;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_error_popup(UiState *state) {
|
||||
if (state->open_error_popup) {
|
||||
ImGui::OpenPopup("Error");
|
||||
state->open_error_popup = false;
|
||||
}
|
||||
if (!ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TextWrapped("%s", state->error_text.c_str());
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool reset_layout(AppSession *session, UiState *state) {
|
||||
try {
|
||||
if (session->layout_path.empty()) {
|
||||
start_new_layout(session, state, "Reset layout");
|
||||
return true;
|
||||
}
|
||||
clear_layout_autosave(*session);
|
||||
session->layout = load_sketch_layout(session->layout_path);
|
||||
state->layout_dirty = false;
|
||||
session->autosave_path = autosave_path_for_layout(session->layout_path);
|
||||
state->undo.reset(session->layout);
|
||||
refresh_replaced_layout_ui(session, state, false);
|
||||
reset_shared_range(state, *session);
|
||||
state->status_text = "Reset layout";
|
||||
return true;
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Failed to reset layout";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg) {
|
||||
try {
|
||||
const bool preserve_shared_range = session->route_data.has_time_range && state->has_shared_range;
|
||||
const double preserved_x_min = state->x_view_min;
|
||||
const double preserved_x_max = state->x_view_max;
|
||||
const fs::path layout_path = resolve_layout_path(layout_arg);
|
||||
session->autosave_path = autosave_path_for_layout(layout_path);
|
||||
const bool load_draft = fs::exists(session->autosave_path);
|
||||
session->layout = load_sketch_layout(load_draft ? session->autosave_path : layout_path);
|
||||
session->layout_path = layout_path;
|
||||
state->layout_dirty = load_draft;
|
||||
state->undo.reset(session->layout);
|
||||
refresh_replaced_layout_ui(session, state, true);
|
||||
if (preserve_shared_range) {
|
||||
state->has_shared_range = true;
|
||||
state->x_view_min = preserved_x_min;
|
||||
state->x_view_max = preserved_x_max;
|
||||
clamp_shared_range(state, *session);
|
||||
} else {
|
||||
reset_shared_range(state, *session);
|
||||
}
|
||||
state->status_text = std::string(load_draft ? "Loaded layout draft " : "Loaded layout ")
|
||||
+ layout_path.filename().string();
|
||||
return true;
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Failed to load layout";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool save_layout(AppSession *session, UiState *state, const std::string &layout_path) {
|
||||
try {
|
||||
if (layout_path.empty()) throw std::runtime_error("Layout path is empty");
|
||||
session->layout.current_tab_index = state->active_tab_index;
|
||||
const fs::path previous_autosave = session->autosave_path;
|
||||
const fs::path output = fs::absolute(fs::path(layout_path));
|
||||
save_layout_json(session->layout, output);
|
||||
session->layout_path = output;
|
||||
session->autosave_path = autosave_path_for_layout(output);
|
||||
if (!previous_autosave.empty() && previous_autosave != session->autosave_path && fs::exists(previous_autosave)) {
|
||||
fs::remove(previous_autosave);
|
||||
}
|
||||
clear_layout_autosave(*session);
|
||||
state->layout_dirty = false;
|
||||
sync_layout_buffers(state, *session);
|
||||
state->status_text = "Saved layout " + output.filename().string();
|
||||
return true;
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Failed to save layout";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void rebuild_session_route_data(AppSession *session, UiState *state,
|
||||
const RouteLoadProgressCallback &progress) {
|
||||
apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress));
|
||||
}
|
||||
|
||||
void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) {
|
||||
if (preserve_data && session->stream_poller && session->data_mode == SessionDataMode::Stream) {
|
||||
session->stream_poller->setPaused(true);
|
||||
} else if (session->stream_poller) {
|
||||
session->stream_poller->stop();
|
||||
}
|
||||
session->stream_paused = preserve_data && session->data_mode == SessionDataMode::Stream;
|
||||
if (!preserve_data) {
|
||||
session->stream_time_offset.reset();
|
||||
apply_route_data(session, state, RouteData{});
|
||||
}
|
||||
sync_stream_buffers(state, *session);
|
||||
}
|
||||
|
||||
bool start_stream_session(AppSession *session,
|
||||
UiState *state,
|
||||
const StreamSourceConfig &source,
|
||||
double buffer_seconds,
|
||||
bool preserve_existing_data) {
|
||||
try {
|
||||
if (session->route_loader) {
|
||||
session->route_loader.reset();
|
||||
}
|
||||
session->data_mode = SessionDataMode::Stream;
|
||||
session->route_id = {};
|
||||
session->route_name.clear();
|
||||
session->data_dir.clear();
|
||||
session->stream_source = source;
|
||||
if (session->stream_source.kind == StreamSourceKind::CerealLocal) {
|
||||
session->stream_source.address = "127.0.0.1";
|
||||
}
|
||||
session->stream_buffer_seconds = std::max(1.0, buffer_seconds);
|
||||
session->next_stream_custom_refresh_time = 0.0;
|
||||
session->stream_paused = false;
|
||||
if (preserve_existing_data && session->stream_poller) {
|
||||
StreamPollSnapshot snapshot = session->stream_poller->snapshot();
|
||||
if (snapshot.active) {
|
||||
session->stream_poller->setPaused(false);
|
||||
sync_route_buffers(state, *session);
|
||||
sync_stream_buffers(state, *session);
|
||||
state->follow_latest = true;
|
||||
state->playback_playing = false;
|
||||
state->status_text = "Resumed stream " + stream_source_target_label(session->stream_source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!preserve_existing_data) {
|
||||
session->stream_time_offset.reset();
|
||||
apply_route_data(session, state, RouteData{});
|
||||
}
|
||||
if (!session->stream_poller) {
|
||||
session->stream_poller = std::make_unique<StreamPoller>();
|
||||
}
|
||||
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<AsyncRouteLoader>(::isatty(STDERR_FILENO) != 0);
|
||||
}
|
||||
start_async_route_load(session, state);
|
||||
} else {
|
||||
rebuild_session_route_data(session, state);
|
||||
state->status_text = "Loaded route " + route_name;
|
||||
}
|
||||
sync_route_buffers(state, *session);
|
||||
return true;
|
||||
} catch (const std::exception &err) {
|
||||
state->error_text = err.what();
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Failed to load route";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void draw_popups(AppSession *session, UiState *state) {
|
||||
open_queued_popup(state->open_open_route, "Open Route");
|
||||
if (state->open_stream) {
|
||||
sync_stream_buffers(state, *session);
|
||||
}
|
||||
open_queued_popup(state->open_stream, "Live Stream");
|
||||
if (state->open_load_layout || state->open_save_layout) {
|
||||
sync_layout_buffers(state, *session);
|
||||
}
|
||||
open_queued_popup(state->open_load_layout, "Load Layout");
|
||||
open_queued_popup(state->open_save_layout, "Save Layout");
|
||||
open_queued_popup(state->open_preferences, "Preferences");
|
||||
open_queued_popup(state->dbc_editor.open, "DBC Editor");
|
||||
open_queued_popup(state->open_find_signal, "Find Signal");
|
||||
open_queued_popup(state->axis_limits.open, "Edit Axis Limits");
|
||||
|
||||
draw_open_route_popup(session, state);
|
||||
draw_stream_popup(session, state);
|
||||
draw_load_layout_popup(session, state);
|
||||
draw_save_layout_popup(session, state);
|
||||
draw_preferences_popup(session, state);
|
||||
draw_dbc_editor_popup(session, state);
|
||||
draw_find_signal_popup(session, state);
|
||||
draw_axis_limits_popup(session, state);
|
||||
draw_error_popup(state);
|
||||
}
|
||||
128
tools/jotpluggler/layout_io.cc
Normal file
128
tools/jotpluggler/layout_io.cc
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "tools/jotpluggler/app.h"
|
||||
#include "tools/jotpluggler/common.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "third_party/json11/json11.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string curve_color_hex(const std::array<uint8_t, 3> &color) {
|
||||
std::ostringstream hex;
|
||||
hex << "#" << std::hex << std::setfill('0')
|
||||
<< std::setw(2) << static_cast<int>(color[0])
|
||||
<< std::setw(2) << static_cast<int>(color[1])
|
||||
<< std::setw(2) << static_cast<int>(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<int>(tab.panes.size())) {
|
||||
return nullptr;
|
||||
}
|
||||
const Pane &pane = tab.panes[static_cast<size_t>(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<double>(node.sizes[i])
|
||||
: 1.0 / static_cast<double>(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<int>(layout.tabs.size()) - 1))},
|
||||
{"tabs", tabs},
|
||||
};
|
||||
write_file_or_throw(path, root.dump() + "\n");
|
||||
}
|
||||
1
tools/jotpluggler/layouts/.gitignore
vendored
Normal file
1
tools/jotpluggler/layouts/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.jotpluggler_autosave/
|
||||
1
tools/jotpluggler/layouts/CAN-bus-debug.json
Normal file
1
tools/jotpluggler/layouts/CAN-bus-debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.33362,0.33276,0.33362],"children":[{"title":"CAN RX","range":{"left":0.0,"right":60.526742,"top":1101.875,"bottom":-26.875},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN TX","range":{"left":0.0,"right":60.526742,"top":455.1,"bottom":-11.1},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#17becf","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#bcbd22","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN errors","range":{"left":0.0,"right":60.526742,"top":2515.35,"bottom":-61.35},"curves":[{"name":"/pandaStates/0/canState0/totalErrorCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalErrorCnt","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalErrorCnt","color":"#1ac938","transform":"derivative","derivative_dt":1.0}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/camera-timings.json
Normal file
1
tools/jotpluggler/layouts/camera-timings.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/driverCameraState/sensor","color":"#bcbd22"},{"name":"/roadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]}
|
||||
1
tools/jotpluggler/layouts/cameras-and-map.json
Normal file
1
tools/jotpluggler/layouts/cameras-and-map.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]}
|
||||
1
tools/jotpluggler/layouts/can-states.json
Normal file
1
tools/jotpluggler/layouts/can-states.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.500381,0.499619],"children":[{"split":"horizontal","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":771630.925,"bottom":-17755.925},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#1f77b4"},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#d62728"},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":760365.5,"bottom":-18545.5},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#9467bd"}]}]},{"split":"horizontal","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":55.35,"bottom":-1.35},"curves":[{"name":"/pandaStates/0/canState0/totalRxLostCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalRxLostCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalRxLostCnt","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":2.05,"bottom":-0.05},"curves":[{"name":"/pandaStates/0/canState0/totalTxLostCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/totalTxLostCnt","color":"#bcbd22"},{"name":"/pandaStates/0/canState2/totalTxLostCnt","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":0.1,"bottom":-0.1},"curves":[{"name":"/pandaStates/0/canState0/busOffCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/busOffCnt","color":"#1ac938"},{"name":"/pandaStates/0/canState2/busOffCnt","color":"#bcbd22"}]}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/controls_mismatch_debug.json
Normal file
1
tools/jotpluggler/layouts/controls_mismatch_debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.2,0.2,0.2,0.2,0.2],"children":[{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#1f77b4"},{"name":"/pandaStates/0/controlsAllowed","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":27.087398,"bottom":-0.905168},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/pandaStates/0/safetyRxInvalid","color":"#1f77b4"},{"name":"/pandaStates/0/safetyRxChecksInvalid","color":"#e801ce"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":158.85,"bottom":-2.85},"curves":[{"name":"/pandaStates/0/safetyTxBlocked","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carState/gasPressed","color":"#1ac938"},{"name":"/carState/brakePressed","color":"#ff7f0e"}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/gps.json
Normal file
1
tools/jotpluggler/layouts/gps.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.24977,0.250689,0.24977,0.24977],"children":[{"title":"...","range":{"left":0.0,"right":1678.753571,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/hasFix","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":17.425,"bottom":-0.425},"curves":[{"name":"/gpsLocationExternal/satelliteCount","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":3.0,"bottom":0.0},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}],"y_limits":{"min":0.0,"max":3.0}},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":766.374004,"bottom":-17.262},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/gps_vs_llk.json
Normal file
1
tools/jotpluggler/layouts/gps_vs_llk.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333805,0.33239,0.333805],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":32.070386,"bottom":0.368228},"curves":[{"name":"haversine distance [m]","color":"#1f77b4","custom_python":{"linked_source":"/gpsLocationExternal/latitude","additional_sources":["/gpsLocationExternal/longitude","/liveLocationKalmanDEPRECATED/positionGeodetic/value/0","/liveLocationKalmanDEPRECATED/positionGeodetic/value/1"],"globals_code":"R = 6378.137 # Radius of earth in KM","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global R\n # Compute the Haversine distance between\n # two points defined by latitude and longitude.\n # Return the distance in meters\n lat1, lon1 = value, v1\n lat2, lon2 = v2, v3\n dLat = (lat2 - lat1) * np.pi / 180\n dLon = (lon2 - lon1) * np.pi / 180\n a = np.sin(dLat/2) * np.sin(dLat/2) +\n np.cos(lat1 * np.pi / 180) * np.cos(lat2 * np.pi / 180) *\n np.sin(dLon/2) * np.sin(dLon/2)\n c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))\n d = R * c\n distance = d * 1000 # meters\n return distance\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":12.637299,"bottom":-0.259115},"curves":[{"name":"/carState/vEgo","color":"#17becf"},{"name":"/gpsLocationExternal/speed","color":"#bcbd22"}]},{"split":"horizontal","sizes":[0.500516,0.499484],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":0.1,"bottom":-0.1},"curves":[{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/0","color":"#d62728"},{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/1","color":"#1ac938"}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":7.160833,"bottom":-0.449385},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#ff7f0e"},{"name":"/gpsLocationExternal/verticalAccuracy","color":"#f14cc1"},{"name":"/gpsLocationExternal/speedAccuracy","color":"#9467bd"}]}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/locationd_debug.json
Normal file
1
tools/jotpluggler/layouts/locationd_debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/livePose/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/liveCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/longitudinal.json
Normal file
1
tools/jotpluggler/layouts/longitudinal.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.250401,0.249599,0.250401,0.249599],"children":[{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.391623,"bottom":-2.563614},"curves":[{"name":"/carState/aEgo","color":"#f14cc1"},{"name":"/longitudinalPlan/accels/0","color":"#9467bd"},{"name":"/carControl/actuators/accel","color":"#17becf"},{"name":"/carOutput/actuatorsOutput/accel","color":"#d62728"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.18496,"bottom":-1.811222},"curves":[{"name":"/controlsState/upAccelCmd","color":"#1f77b4"},{"name":"/controlsState/uiAccelCmd","color":"#d62728"},{"name":"/controlsState/ufAccelCmd","color":"#1ac938"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":15.862889,"bottom":-0.568809},"curves":[{"name":"/carState/vEgo","color":"#1ac938"},{"name":"/longitudinalPlan/speeds/0","color":"#ff7f0e"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/longActive","color":"#1f77b4"},{"name":"/carState/gasPressed","color":"#d62728"}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/max-torque-debug.json
Normal file
1
tools/jotpluggler/layouts/max-torque-debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/new-layout.json
Normal file
1
tools/jotpluggler/layouts/new-layout.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"curves": [], "title": "..."}}]}
|
||||
1
tools/jotpluggler/layouts/system_lag_debug.json
Normal file
1
tools/jotpluggler/layouts/system_lag_debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249729,0.250814,0.249729,0.249729],"children":[{"title":"...","range":{"left":0.0,"right":59.992103,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"},{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":64.005001,"bottom":51.195},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#d62728"},{"name":"/deviceState/cpuTempC/1","color":"#1ac938"},{"name":"/deviceState/cpuTempC/2","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/3","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/4","color":"#9467bd"},{"name":"/deviceState/cpuTempC/5","color":"#17becf"},{"name":"/deviceState/cpuTempC/6","color":"#bcbd22"},{"name":"/deviceState/cpuTempC/7","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":37.371108,"bottom":-0.91149},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":-3.593455,"bottom":-12.190956},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/thermal_debug.json
Normal file
1
tools/jotpluggler/layouts/thermal_debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166785,0.166785,0.166075,0.166785,0.166785,0.166785],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":87.987497,"bottom":75.912497},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#1f77b4"},{"name":"/deviceState/cpuTempC/1","color":"#d62728"},{"name":"/deviceState/cpuTempC/2","color":"#1ac938"},{"name":"/deviceState/cpuTempC/3","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/4","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/5","color":"#9467bd"},{"name":"/deviceState/cpuTempC/6","color":"#17becf"},{"name":"/deviceState/cpuTempC/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":85.861052,"bottom":66.49695},"curves":[{"name":"/deviceState/pmicTempC/0","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"},{"name":"/deviceState/memoryTempC","color":"#f14cc1"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":86.207876,"bottom":70.665918},"curves":[{"name":"/deviceState/maxTempC","color":"#1f77b4"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceState/thermalStatus","color":"#1f77b4"}]},{"split":"horizontal","sizes":[0.333124,0.333752,0.333124],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":12.057358,"bottom":4.843517},"curves":[{"name":"/deviceState/powerDrawW","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.0,"bottom":0.0},"curves":[{"name":"/deviceState/fanSpeedPercentDesired","color":"#9467bd"},{"name":"/pandaStates/0/fanPower","color":"#1f77b4"}],"y_limits":{"min":0.0,"max":100.0}},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":5018.4,"bottom":255.6},"curves":[{"name":"/peripheralState/fanSpeedRpm","color":"#1f77b4"}]}]},{"split":"horizontal","sizes":[0.502513,0.497487],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.025,"bottom":14.975},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]}]}]}}]}
|
||||
1
tools/jotpluggler/layouts/torque-controller.json
Normal file
1
tools/jotpluggler/layouts/torque-controller.json
Normal file
File diff suppressed because one or more lines are too long
1
tools/jotpluggler/layouts/tuning.json
Normal file
1
tools/jotpluggler/layouts/tuning.json
Normal file
File diff suppressed because one or more lines are too long
1
tools/jotpluggler/layouts/ublox-debug.json
Normal file
1
tools/jotpluggler/layouts/ublox-debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":134.825489,"top":4402341.574525,"bottom":-107369.555525},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/flags","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":6.15,"bottom":-0.15},"curves":[{"name":"/ubloxGnss/measurementReport/numMeas","color":"#1ac938"}]}]}}]}
|
||||
419
tools/jotpluggler/logs.cc
Normal file
419
tools/jotpluggler/logs.cc
Normal file
@@ -0,0 +1,419 @@
|
||||
#include "tools/jotpluggler/app.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
|
||||
namespace {
|
||||
|
||||
struct LevelOption {
|
||||
const char *label;
|
||||
int value;
|
||||
};
|
||||
|
||||
constexpr std::array<LevelOption, 5> LEVEL_OPTIONS = {{
|
||||
{"DEBUG", 10},
|
||||
{"INFO", 20},
|
||||
{"WARNING", 30},
|
||||
{"ERROR", 40},
|
||||
{"CRITICAL", 50},
|
||||
}};
|
||||
constexpr uint32_t ALL_LEVEL_MASK = (1u << LEVEL_OPTIONS.size()) - 1u;
|
||||
|
||||
bool log_matches_search(const LogEntry &entry, std::string_view query) {
|
||||
if (query.empty()) return true;
|
||||
const std::string needle = lowercase_copy(query);
|
||||
const auto contains = [&](std::string_view haystack) {
|
||||
return lowercase_copy(haystack).find(needle) != std::string::npos;
|
||||
};
|
||||
return contains(entry.message) || contains(entry.source) || contains(entry.func);
|
||||
}
|
||||
|
||||
std::vector<std::string> collect_log_sources(const std::vector<LogEntry> &logs) {
|
||||
std::vector<std::string> 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<int> filter_log_indices(const RouteData &route_data, const LogsUiState &logs_state) {
|
||||
std::vector<int> 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<int>(i));
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
int find_active_log_position(const RouteData &route_data,
|
||||
const std::vector<int> &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<size_t>(log_index)].mono_time < tm;
|
||||
});
|
||||
if (it == filtered_indices.begin()) return static_cast<int>(std::distance(filtered_indices.begin(), it));
|
||||
if (it == filtered_indices.end()) return static_cast<int>(filtered_indices.size()) - 1;
|
||||
if (route_data.logs[static_cast<size_t>(*it)].mono_time > tracker_time) {
|
||||
--it;
|
||||
}
|
||||
return static_cast<int>(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<int>(seconds / 60.0);
|
||||
const double remaining = seconds - static_cast<double>(minutes) * 60.0;
|
||||
return util::string_format("%d:%06.3f", minutes, remaining);
|
||||
}
|
||||
|
||||
std::string format_boot_time(double seconds) {
|
||||
return util::string_format("%.3f", seconds);
|
||||
}
|
||||
|
||||
std::string format_wall_time(double seconds) {
|
||||
if (seconds <= 0.0) return "--";
|
||||
const time_t wall_seconds = static_cast<time_t>(seconds);
|
||||
std::tm wall_tm = {};
|
||||
localtime_r(&wall_seconds, &wall_tm);
|
||||
const int millis = static_cast<int>(std::llround((seconds - std::floor(seconds)) * 1000.0));
|
||||
return util::string_format("%02d:%02d:%02d.%03d",
|
||||
wall_tm.tm_hour, wall_tm.tm_min, wall_tm.tm_sec, millis);
|
||||
}
|
||||
|
||||
std::string format_log_time(const LogEntry &entry, LogTimeMode mode) {
|
||||
switch (mode) {
|
||||
case LogTimeMode::Route:
|
||||
return format_route_time(entry.mono_time);
|
||||
case LogTimeMode::Boot:
|
||||
return format_boot_time(entry.boot_time);
|
||||
case LogTimeMode::WallClock:
|
||||
return format_wall_time(entry.wall_time);
|
||||
}
|
||||
return format_route_time(entry.mono_time);
|
||||
}
|
||||
|
||||
const char *time_mode_label(LogTimeMode mode) {
|
||||
switch (mode) {
|
||||
case LogTimeMode::Route: return "Route";
|
||||
case LogTimeMode::Boot: return "Boot";
|
||||
case LogTimeMode::WallClock: return "Wall clock";
|
||||
}
|
||||
return "Route";
|
||||
}
|
||||
|
||||
std::string level_filter_label(uint32_t mask) {
|
||||
if (mask == ALL_LEVEL_MASK) return "All levels";
|
||||
if (mask == 0b11110) return "INFO+";
|
||||
if (mask == 0b11100) return "WARNING+";
|
||||
if (mask == 0b11000) return "ERROR+";
|
||||
if (mask == 0b10000) return "CRITICAL";
|
||||
|
||||
int enabled_count = 0;
|
||||
const char *last_label = "None";
|
||||
for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) {
|
||||
if ((mask & (1u << i)) == 0) {
|
||||
continue;
|
||||
}
|
||||
++enabled_count;
|
||||
last_label = LEVEL_OPTIONS[i].label;
|
||||
}
|
||||
if (enabled_count == 0) return "None";
|
||||
if (enabled_count == 1) return last_label;
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
std::string source_filter_label(const LogsUiState &logs_state, const std::vector<std::string> &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<std::string> 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<int> 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<float>(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<int>(filtered_indices.size()));
|
||||
while (clipper.Step()) {
|
||||
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) {
|
||||
const int log_index = filtered_indices[static_cast<size_t>(i)];
|
||||
const LogEntry &entry = route_data.logs[static_cast<size_t>(log_index)];
|
||||
draw_log_row(entry, log_index, i == active_pos, state);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < static_cast<int>(filtered_indices.size()); ++i) {
|
||||
const int log_index = filtered_indices[static_cast<size_t>(i)];
|
||||
const LogEntry &entry = route_data.logs[static_cast<size_t>(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();
|
||||
}
|
||||
126
tools/jotpluggler/main.cc
Normal file
126
tools/jotpluggler/main.cc
Normal file
@@ -0,0 +1,126 @@
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
#include "tools/jotpluggler/app.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496";
|
||||
|
||||
void print_usage(const char *argv0) {
|
||||
std::cerr
|
||||
<< "Usage: " << argv0 << " [--layout <layout>] [options] [route]\n"
|
||||
<< "\n"
|
||||
<< "Options:\n"
|
||||
<< " --demo\n"
|
||||
<< " --data-dir <dir>\n"
|
||||
<< " --stream\n"
|
||||
<< " --address <host>\n"
|
||||
<< " --buffer-seconds <seconds>\n"
|
||||
<< " --width <pixels>\n"
|
||||
<< " --height <pixels>\n"
|
||||
<< " --output <png>\n"
|
||||
<< " --show\n"
|
||||
<< " --sync-load\n"
|
||||
<< "\n"
|
||||
<< "Examples:\n"
|
||||
<< " " << argv0 << "\n"
|
||||
<< " " << argv0 << " --demo\n"
|
||||
<< " " << argv0 << " --layout longitudinal --demo\n"
|
||||
<< " " << argv0 << " --layout longitudinal --demo --output /tmp/longitudinal.png\n"
|
||||
<< " " << argv0 << " --stream --show\n"
|
||||
<< " " << argv0 << " --stream --address 192.168.60.52 --buffer-seconds 45 --show\n";
|
||||
}
|
||||
|
||||
bool parse_int(const char *value, int *out) {
|
||||
char *end = nullptr;
|
||||
const long parsed = std::strtol(value, &end, 10);
|
||||
if (end == nullptr || *end != '\0') return false;
|
||||
*out = static_cast<int>(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parse_double(const char *value, double *out) {
|
||||
char *end = nullptr;
|
||||
const double parsed = std::strtod(value, &end);
|
||||
if (end == nullptr || *end != '\0') return false;
|
||||
*out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
Options options;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
const auto require_value = [&](const char *flag) -> const char * {
|
||||
if (i + 1 >= argc) {
|
||||
std::cerr << "Missing value for " << flag << "\n";
|
||||
print_usage(argv[0]);
|
||||
std::exit(2);
|
||||
}
|
||||
return argv[++i];
|
||||
};
|
||||
|
||||
if (arg == "--layout") {
|
||||
options.layout = require_value("--layout");
|
||||
} else if (arg == "--demo") {
|
||||
options.route_name = DEMO_ROUTE;
|
||||
} else if (arg == "--data-dir") {
|
||||
options.data_dir = require_value("--data-dir");
|
||||
} else if (arg == "--stream") {
|
||||
options.stream = true;
|
||||
} else if (arg == "--address") {
|
||||
options.stream_address = require_value("--address");
|
||||
} else if (arg == "--buffer-seconds") {
|
||||
if (!parse_double(require_value("--buffer-seconds"), &options.stream_buffer_seconds)) {
|
||||
std::cerr << "Invalid buffer seconds\n";
|
||||
return 2;
|
||||
}
|
||||
} else if (arg == "--output") {
|
||||
options.output_path = require_value("--output");
|
||||
} else if (arg == "--width") {
|
||||
if (!parse_int(require_value("--width"), &options.width)) {
|
||||
std::cerr << "Invalid width\n";
|
||||
return 2;
|
||||
}
|
||||
} else if (arg == "--height") {
|
||||
if (!parse_int(require_value("--height"), &options.height)) {
|
||||
std::cerr << "Invalid height\n";
|
||||
return 2;
|
||||
}
|
||||
} else if (arg == "--show") {
|
||||
options.show = true;
|
||||
} else if (arg == "--sync-load") {
|
||||
options.sync_load = true;
|
||||
} else if (arg == "--help" || arg == "-h") {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
} else if (!arg.empty() && arg[0] != '-' && options.route_name.empty()) {
|
||||
options.route_name = arg;
|
||||
} else {
|
||||
std::cerr << "Unknown argument: " << arg << "\n";
|
||||
print_usage(argv[0]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.output_path.empty() && !options.show) {
|
||||
options.show = true;
|
||||
}
|
||||
if (options.width <= 0 || options.height <= 0) {
|
||||
std::cerr << "Width and height must be positive\n";
|
||||
return 2;
|
||||
}
|
||||
if (options.stream && !options.route_name.empty()) {
|
||||
std::cerr << "Route/file mode and --stream are mutually exclusive\n";
|
||||
return 2;
|
||||
}
|
||||
if (options.stream_buffer_seconds <= 0.0) {
|
||||
std::cerr << "Buffer seconds must be positive\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
return run(options);
|
||||
}
|
||||
1328
tools/jotpluggler/map.cc
Normal file
1328
tools/jotpluggler/map.cc
Normal file
File diff suppressed because it is too large
Load Diff
61
tools/jotpluggler/map.h
Normal file
61
tools/jotpluggler/map.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
struct GpsTrace;
|
||||
struct GeoBounds {
|
||||
double south = 0.0;
|
||||
double west = 0.0;
|
||||
double north = 0.0;
|
||||
double east = 0.0;
|
||||
|
||||
bool valid() const {
|
||||
return south < north && west < east;
|
||||
}
|
||||
};
|
||||
|
||||
struct RouteBasemap;
|
||||
struct MapCacheStats {
|
||||
uint64_t bytes = 0;
|
||||
size_t files = 0;
|
||||
};
|
||||
|
||||
class MapDataManager {
|
||||
public:
|
||||
MapDataManager();
|
||||
~MapDataManager();
|
||||
|
||||
MapDataManager(const MapDataManager &) = delete;
|
||||
MapDataManager &operator=(const MapDataManager &) = delete;
|
||||
|
||||
void pump();
|
||||
void ensureTrace(const GpsTrace &trace);
|
||||
void clearCache();
|
||||
bool loading() const;
|
||||
const RouteBasemap *current() const;
|
||||
MapCacheStats cacheStats() const;
|
||||
|
||||
private:
|
||||
struct Request {
|
||||
std::string key;
|
||||
GeoBounds bounds;
|
||||
std::string query;
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
bool stopping_ = false;
|
||||
std::unique_ptr<Request> pending_;
|
||||
std::unique_ptr<Request> active_;
|
||||
std::unique_ptr<RouteBasemap> completed_;
|
||||
std::unique_ptr<RouteBasemap> current_;
|
||||
std::thread worker_;
|
||||
};
|
||||
145
tools/jotpluggler/math_eval.py
Executable file
145
tools/jotpluggler/math_eval.py
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
import traceback
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _load_manifest(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _load_vector(path: str) -> np.ndarray:
|
||||
return np.fromfile(path, dtype=np.float64)
|
||||
|
||||
|
||||
def _write_vector(path: str, values: np.ndarray) -> None:
|
||||
np.asarray(values, dtype=np.float64).tofile(path)
|
||||
|
||||
|
||||
def _resample_to_reference(ref_t: np.ndarray, src_t: np.ndarray, src_v: np.ndarray) -> np.ndarray:
|
||||
ref_t = np.asarray(ref_t, dtype=np.float64).reshape(-1)
|
||||
src_t = np.asarray(src_t, dtype=np.float64).reshape(-1)
|
||||
src_v = np.asarray(src_v, dtype=np.float64).reshape(-1)
|
||||
if ref_t.size == 0 or src_t.size == 0 or src_v.size == 0:
|
||||
return np.empty_like(ref_t)
|
||||
indices = np.searchsorted(src_t, ref_t, side="right") - 1
|
||||
indices = np.clip(indices, 0, src_v.size - 1)
|
||||
return src_v[indices]
|
||||
|
||||
|
||||
def _evaluate_user_code(code: str, env: dict):
|
||||
stripped = code.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Function body is empty")
|
||||
|
||||
expr = stripped
|
||||
if expr.startswith("return "):
|
||||
expr = expr[7:].strip()
|
||||
try:
|
||||
return eval(expr, env, env)
|
||||
except SyntaxError:
|
||||
pass
|
||||
|
||||
function_src = "def __jotpluggler_eval__():\n" + textwrap.indent(code, " ")
|
||||
exec(function_src, env, env)
|
||||
return env["__jotpluggler_eval__"]()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 6:
|
||||
print("usage: math_eval.py <manifest.json> <globals.py> <code.py> <out_t.bin> <out_v.bin>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
manifest_path, globals_path, code_path, out_t_path, out_v_path = sys.argv[1:6]
|
||||
manifest = _load_manifest(manifest_path)
|
||||
|
||||
series_t = {}
|
||||
series_v = {}
|
||||
for entry in manifest.get("series", []):
|
||||
path = entry["path"]
|
||||
series_t[path] = _load_vector(entry["t"])
|
||||
series_v[path] = _load_vector(entry["v"])
|
||||
|
||||
first_path = manifest.get("linked_source") or None
|
||||
|
||||
def remember(path: str) -> None:
|
||||
nonlocal first_path
|
||||
if first_path is None:
|
||||
first_path = path
|
||||
|
||||
def t(path: str) -> np.ndarray:
|
||||
remember(path)
|
||||
return series_t[path]
|
||||
|
||||
def v(path: str) -> np.ndarray:
|
||||
remember(path)
|
||||
return series_v[path]
|
||||
|
||||
additional_sources = list(manifest.get("additional_sources", []))
|
||||
linked_source = manifest.get("linked_source") or ""
|
||||
paths = list(manifest.get("paths", []))
|
||||
|
||||
env = {
|
||||
"__builtins__": __builtins__,
|
||||
"np": np,
|
||||
"t": t,
|
||||
"v": v,
|
||||
"paths": paths,
|
||||
"linked_source": linked_source,
|
||||
"additional_sources": additional_sources,
|
||||
}
|
||||
|
||||
reference_time = None
|
||||
if linked_source:
|
||||
reference_time = series_t[linked_source]
|
||||
env["time"] = reference_time
|
||||
env["value"] = series_v[linked_source]
|
||||
|
||||
for i, path in enumerate(additional_sources, start=1):
|
||||
if reference_time is None:
|
||||
env[f"t{i}"] = series_t[path]
|
||||
env[f"v{i}"] = series_v[path]
|
||||
else:
|
||||
env[f"t{i}"] = reference_time
|
||||
env[f"v{i}"] = _resample_to_reference(reference_time, series_t[path], series_v[path])
|
||||
|
||||
with open(globals_path, encoding="utf-8") as f:
|
||||
globals_code = f.read()
|
||||
if globals_code.strip():
|
||||
exec(globals_code, env, env)
|
||||
|
||||
with open(code_path, encoding="utf-8") as f:
|
||||
user_code = f.read()
|
||||
result = _evaluate_user_code(user_code, env)
|
||||
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
result_t, result_v = result
|
||||
else:
|
||||
if first_path is None:
|
||||
raise ValueError("No reference series found. Set an input timeseries or return (times, values).")
|
||||
result_t = series_t[first_path]
|
||||
result_v = result
|
||||
|
||||
result_t = np.asarray(result_t, dtype=np.float64).reshape(-1)
|
||||
result_v = np.asarray(result_v, dtype=np.float64).reshape(-1)
|
||||
if result_t.size == 0 or result_v.size == 0:
|
||||
raise ValueError("Custom series returned an empty result")
|
||||
if result_t.shape != result_v.shape:
|
||||
raise ValueError(f"Time/value arrays must have the same shape, got {result_t.shape} and {result_v.shape}")
|
||||
|
||||
_write_vector(out_t_path, result_t)
|
||||
_write_vector(out_v_path, result_v)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as err:
|
||||
traceback.print_exc()
|
||||
raise SystemExit(1) from err
|
||||
1027
tools/jotpluggler/plot.cc
Normal file
1027
tools/jotpluggler/plot.cc
Normal file
File diff suppressed because it is too large
Load Diff
173
tools/jotpluggler/render.cc
Normal file
173
tools/jotpluggler/render.cc
Normal file
@@ -0,0 +1,173 @@
|
||||
#include "tools/jotpluggler/internal.h"
|
||||
|
||||
#include "imgui_impl_glfw.h"
|
||||
#include "imgui_impl_opengl3.h"
|
||||
#include "imgui_impl_opengl3_loader.h"
|
||||
|
||||
#include <GLFW/glfw3.h>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
void draw_fps_overlay(const UiState &state, float top_offset) {
|
||||
if (!state.show_fps_overlay) {
|
||||
return;
|
||||
}
|
||||
ImGuiViewport *viewport = ImGui::GetMainViewport();
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
const float fps = io.Framerate;
|
||||
const std::string label = util::string_format("%.1f fps", fps);
|
||||
|
||||
const ImVec2 padding(10.0f, 8.0f);
|
||||
const ImVec2 margin(12.0f, 10.0f);
|
||||
app_push_mono_font();
|
||||
ImFont *font = ImGui::GetFont();
|
||||
const float font_size = ImGui::GetFontSize();
|
||||
const ImVec2 text_size = ImGui::CalcTextSize(label.c_str());
|
||||
app_pop_mono_font();
|
||||
const ImVec2 size(text_size.x + padding.x * 2.0f, text_size.y + padding.y * 2.0f);
|
||||
const ImVec2 pos(viewport->Pos.x + viewport->Size.x - size.x - margin.x,
|
||||
viewport->Pos.y + top_offset + margin.y);
|
||||
ImDrawList *draw_list = ImGui::GetForegroundDrawList(viewport);
|
||||
const ImVec2 max(pos.x + size.x, pos.y + size.y);
|
||||
draw_list->AddRectFilled(pos, max, ImGui::GetColorU32(color_rgb(248, 249, 251, 0.92f)), 4.0f);
|
||||
draw_list->AddRect(pos, max, ImGui::GetColorU32(color_rgb(182, 188, 196, 0.95f)), 4.0f);
|
||||
draw_list->AddText(font, font_size, ImVec2(pos.x + padding.x, pos.y + padding.y),
|
||||
ImGui::GetColorU32(color_rgb(57, 62, 69)), label.c_str(), nullptr);
|
||||
}
|
||||
|
||||
void render_layout(AppSession *session, UiState *state, bool show_camera_feed) {
|
||||
if (!state->fps_overlay_initialized) {
|
||||
state->show_fps_overlay = false;
|
||||
state->fps_overlay_initialized = true;
|
||||
}
|
||||
ensure_shared_range(state, *session);
|
||||
if (state->follow_latest) {
|
||||
update_follow_range(state, *session);
|
||||
state->suppress_range_side_effects = true;
|
||||
} else {
|
||||
clamp_shared_range(state, *session);
|
||||
}
|
||||
const bool ctrl = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper;
|
||||
const bool shift = ImGui::GetIO().KeyShift;
|
||||
if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) {
|
||||
if (shift) {
|
||||
apply_redo(session, state);
|
||||
} else {
|
||||
apply_undo(session, state);
|
||||
}
|
||||
}
|
||||
if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) {
|
||||
state->open_find_signal = true;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
|
||||
step_tracker(state, -1.0);
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
|
||||
step_tracker(state, 1.0);
|
||||
}
|
||||
if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) {
|
||||
state->playback_playing = !state->playback_playing;
|
||||
}
|
||||
advance_playback(state, *session);
|
||||
CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast<size_t>(sidebar_preview_camera_view(*session))].get();
|
||||
if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) {
|
||||
sidebar_camera->update(state->tracker_time);
|
||||
}
|
||||
const float menu_height = draw_main_menu_bar(session, state);
|
||||
UiMetrics ui = compute_ui_metrics(ImGui::GetMainViewport()->Size, menu_height, state->sidebar_width);
|
||||
if (state->browser_nodes_dirty) {
|
||||
rebuild_browser_nodes(session, state);
|
||||
state->browser_nodes_dirty = false;
|
||||
}
|
||||
state->sidebar_width = ui.sidebar_width;
|
||||
draw_sidebar(session, ui, state, show_camera_feed);
|
||||
draw_workspace(session, ui, state);
|
||||
draw_sidebar_resizer(ui, state);
|
||||
if (!state->custom_series.selected && !state->logs.selected) {
|
||||
draw_pane_windows(session, state);
|
||||
}
|
||||
draw_status_bar(*session, ui, state);
|
||||
draw_popups(session, state);
|
||||
draw_fps_overlay(*state, menu_height);
|
||||
}
|
||||
|
||||
void save_framebuffer_png(const fs::path &output_path, int width, int height) {
|
||||
ensure_parent_dir(output_path);
|
||||
if (width <= 0 || height <= 0) throw std::runtime_error("Invalid framebuffer size");
|
||||
|
||||
std::vector<uint8_t> pixels(static_cast<size_t>(width) * static_cast<size_t>(height) * 4U, 0);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
|
||||
const fs::path ppm_path = output_path.parent_path() / (output_path.stem().string() + ".ppm");
|
||||
std::string ppm = util::string_format("P6\n%d %d\n255\n", width, height);
|
||||
ppm.reserve(ppm.size() + static_cast<size_t>(width) * static_cast<size_t>(height) * 3U);
|
||||
for (int y = height - 1; y >= 0; --y) {
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const size_t index = static_cast<size_t>((y * width + x) * 4);
|
||||
ppm.append(reinterpret_cast<const char *>(&pixels[index]), 3);
|
||||
}
|
||||
}
|
||||
write_file_or_throw(ppm_path, ppm.data(), ppm.size());
|
||||
|
||||
const std::string command = "convert " + shell_quote(ppm_path.string()) + " " + shell_quote(output_path.string());
|
||||
run_system_or_throw(command, "image conversion");
|
||||
fs::remove(ppm_path);
|
||||
}
|
||||
|
||||
void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const fs::path *capture_path) {
|
||||
glfwPollEvents();
|
||||
|
||||
int framebuffer_width = 0;
|
||||
int framebuffer_height = 0;
|
||||
glfwGetFramebufferSize(window, &framebuffer_width, &framebuffer_height);
|
||||
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplGlfw_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
if (state->request_save_layout) {
|
||||
if (session->layout_path.empty()) {
|
||||
state->open_save_layout = true;
|
||||
} else {
|
||||
save_layout(session, state, session->layout_path.string());
|
||||
}
|
||||
state->request_save_layout = false;
|
||||
}
|
||||
if (state->request_reset_layout) {
|
||||
reset_layout(session, state);
|
||||
state->request_reset_layout = false;
|
||||
}
|
||||
poll_async_route_load(session, state);
|
||||
if (session->data_mode == SessionDataMode::Stream && session->stream_poller) {
|
||||
StreamExtractBatch batch;
|
||||
std::string error_text;
|
||||
if (session->stream_poller->consume(&batch, &error_text)) {
|
||||
if (!error_text.empty()) {
|
||||
state->error_text = error_text;
|
||||
state->open_error_popup = true;
|
||||
state->status_text = "Stream disconnected";
|
||||
} else {
|
||||
apply_stream_batch(session, state, std::move(batch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool show_camera = capture_path == nullptr && session->data_mode != SessionDataMode::Stream;
|
||||
render_layout(session, state, show_camera);
|
||||
ImGui::Render();
|
||||
if (state->request_close) {
|
||||
glfwSetWindowShouldClose(window, GLFW_TRUE);
|
||||
state->request_close = false;
|
||||
}
|
||||
|
||||
glViewport(0, 0, framebuffer_width, framebuffer_height);
|
||||
glClearColor(227.0f / 255.0f, 229.0f / 255.0f, 233.0f / 255.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
if (capture_path != nullptr) {
|
||||
save_framebuffer_png(*capture_path, framebuffer_width, framebuffer_height);
|
||||
}
|
||||
glfwSwapBuffers(window);
|
||||
state->suppress_range_side_effects = false;
|
||||
}
|
||||
1280
tools/jotpluggler/runtime.cc
Normal file
1280
tools/jotpluggler/runtime.cc
Normal file
File diff suppressed because it is too large
Load Diff
773
tools/jotpluggler/session.cc
Normal file
773
tools/jotpluggler/session.cc
Normal file
@@ -0,0 +1,773 @@
|
||||
#include "tools/jotpluggler/internal.h"
|
||||
|
||||
#include "imgui_internal.h"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path) {
|
||||
auto it = session.series_by_path.find(path);
|
||||
return it == session.series_by_path.end() ? nullptr : it->second;
|
||||
}
|
||||
|
||||
void sync_camera_feeds(AppSession *session) {
|
||||
for (size_t i = 0; i < kCameraViewSpecs.size(); ++i) {
|
||||
if (session->pane_camera_feeds[i]) {
|
||||
session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void apply_route_data(AppSession *session, UiState *state, RouteData route_data) {
|
||||
if (!route_data.route_id.empty()) {
|
||||
session->route_id = route_data.route_id;
|
||||
} else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) {
|
||||
session->route_id = {};
|
||||
}
|
||||
session->route_data = std::move(route_data);
|
||||
rebuild_route_index(session);
|
||||
rebuild_browser_nodes(session, state);
|
||||
state->browser_nodes_dirty = false;
|
||||
refresh_all_custom_curves(session, state);
|
||||
sync_camera_feeds(session);
|
||||
state->has_shared_range = false;
|
||||
state->has_tracker_time = false;
|
||||
reset_shared_range(state, *session);
|
||||
}
|
||||
|
||||
bool restore_undo_layout(AppSession *session, UiState *state, const SketchLayout &layout, const char *status_text) {
|
||||
session->layout = layout;
|
||||
cancel_rename_tab(state);
|
||||
state->custom_series.request_select = false;
|
||||
state->active_tab_index = std::clamp(layout.current_tab_index, 0, std::max(0, static_cast<int>(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<std::pair<double, double>> 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<std::pair<double, double>> 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<double>(ImGui::GetIO().DeltaTime)) * state->playback_rate;
|
||||
const double view_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min);
|
||||
const double loop_min = state->playback_loop
|
||||
? std::clamp(state->x_view_min, state->route_x_min, state->route_x_max)
|
||||
: state->route_x_min;
|
||||
const double loop_max = state->playback_loop
|
||||
? std::clamp(state->x_view_max, state->route_x_min, state->route_x_max)
|
||||
: state->route_x_max;
|
||||
|
||||
state->tracker_time += delta;
|
||||
if (state->tracker_time >= loop_max) {
|
||||
if (state->playback_loop) {
|
||||
state->tracker_time = loop_min;
|
||||
} else {
|
||||
state->tracker_time = state->route_x_max;
|
||||
state->playback_playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state->playback_loop) {
|
||||
constexpr double kScrollStartFraction = 0.70;
|
||||
const double scroll_anchor = state->x_view_min + view_span * kScrollStartFraction;
|
||||
if (state->tracker_time > scroll_anchor && state->x_view_max < state->route_x_max) {
|
||||
state->x_view_min = state->tracker_time - view_span * kScrollStartFraction;
|
||||
state->x_view_max = state->x_view_min + view_span;
|
||||
clamp_shared_range(state, session);
|
||||
} else if (state->tracker_time < state->x_view_min || state->tracker_time > state->x_view_max) {
|
||||
state->x_view_min = state->tracker_time - view_span * 0.5;
|
||||
state->x_view_max = state->x_view_min + view_span;
|
||||
clamp_shared_range(state, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void step_tracker(UiState *state, double direction) {
|
||||
if (!state->has_shared_range) {
|
||||
return;
|
||||
}
|
||||
state->tracker_time += direction * std::max(0.001, state->playback_step);
|
||||
state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max);
|
||||
}
|
||||
|
||||
const char *log_selector_name(LogSelector selector) {
|
||||
static constexpr const char *kLabels[] = {"a", "r", "q"};
|
||||
const size_t index = static_cast<size_t>(selector);
|
||||
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
|
||||
}
|
||||
|
||||
const char *log_selector_description(LogSelector selector) {
|
||||
static constexpr const char *kLabels[] = {
|
||||
"any of rlog or qlog",
|
||||
"rlog only",
|
||||
"qlog only",
|
||||
};
|
||||
const size_t index = static_cast<size_t>(selector);
|
||||
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
|
||||
}
|
||||
|
||||
std::string shorten_route_part(std::string_view text, size_t keep) {
|
||||
if (text.size() <= keep) {
|
||||
return std::string(text);
|
||||
}
|
||||
return std::string(text.substr(0, keep));
|
||||
}
|
||||
|
||||
bool parse_slice_spec(std::string_view text, int *begin, int *end) {
|
||||
const auto parse_nonnegative = [](std::string_view value, int *out) {
|
||||
if (value.empty()) return false;
|
||||
char *end_ptr = nullptr;
|
||||
const long parsed = std::strtol(std::string(value).c_str(), &end_ptr, 10);
|
||||
if (end_ptr == nullptr || *end_ptr != '\0' || parsed < 0) {
|
||||
return false;
|
||||
}
|
||||
*out = static_cast<int>(parsed);
|
||||
return true;
|
||||
};
|
||||
const std::string trimmed = util::strip(std::string(text));
|
||||
if (trimmed.empty()) {
|
||||
return false;
|
||||
}
|
||||
const size_t colon = trimmed.find(':');
|
||||
int parsed_begin = 0;
|
||||
if (!parse_nonnegative(trimmed.substr(0, colon), &parsed_begin)) {
|
||||
return false;
|
||||
}
|
||||
int parsed_end = parsed_begin;
|
||||
if (colon != std::string::npos) {
|
||||
const std::string end_text = trimmed.substr(colon + 1);
|
||||
if (end_text.empty()) {
|
||||
parsed_end = -1;
|
||||
} else if (!parse_nonnegative(end_text, &parsed_end) || parsed_end < parsed_begin) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*begin = parsed_begin;
|
||||
*end = parsed_end;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string format_duration_short(double seconds) {
|
||||
const double clamped = std::max(0.0, seconds);
|
||||
const int total_ms = static_cast<int>(std::round(clamped * 1000.0));
|
||||
const int minutes = total_ms / 60000;
|
||||
const int rem_ms = total_ms % 60000;
|
||||
const int secs = rem_ms / 1000;
|
||||
const int millis = rem_ms % 1000;
|
||||
return util::string_format("%d:%02d.%03d", minutes, secs, millis);
|
||||
}
|
||||
|
||||
bool apply_route_identifier(AppSession *session, UiState *state, const RouteIdentifier &route_id, const char *status_text) {
|
||||
if (route_id.empty()) {
|
||||
return false;
|
||||
}
|
||||
if (!reload_session(session, state, route_id.full_spec(), session->data_dir)) {
|
||||
return false;
|
||||
}
|
||||
state->status_text = status_text;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool apply_route_slice_change(AppSession *session, UiState *state, std::string_view slice_text) {
|
||||
int begin = 0;
|
||||
int end = 0;
|
||||
if (!parse_slice_spec(slice_text, &begin, &end)) {
|
||||
state->error_text = "Slice must be N or N:M.";
|
||||
state->open_error_popup = true;
|
||||
return false;
|
||||
}
|
||||
RouteIdentifier next = session->route_id;
|
||||
next.slice_begin = begin;
|
||||
next.slice_end = end;
|
||||
next.slice_explicit = true;
|
||||
return apply_route_identifier(session, state, next, "Updated route slice");
|
||||
}
|
||||
|
||||
bool apply_route_selector_change(AppSession *session, UiState *state, LogSelector selector) {
|
||||
RouteIdentifier next = session->route_id;
|
||||
next.selector = selector;
|
||||
next.selector_explicit = true;
|
||||
return apply_route_identifier(session, state, next, "Updated log selector");
|
||||
}
|
||||
|
||||
ImU32 route_chip_part_color(int part_index, bool explicit_part) {
|
||||
constexpr std::array<std::array<int, 3>, 4> BASE = {{
|
||||
{70, 96, 126}, // dongle
|
||||
{100, 86, 148}, // log id
|
||||
{72, 112, 86}, // slice
|
||||
{156, 104, 38}, // selector
|
||||
}};
|
||||
const std::array<int, 3> &base = BASE[static_cast<size_t>(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<float>(std::clamp((state->route_copy_feedback_until - now) / 1.1, 0.0, 1.0));
|
||||
const ImVec2 text_size = ImGui::CalcTextSize(state->route_copy_feedback_text.c_str());
|
||||
const ImVec2 pad(9.0f, 5.0f);
|
||||
const ImVec2 bubble_min(chip_max.x - text_size.x - pad.x * 2.0f, chip_max.y + 7.0f);
|
||||
const ImVec2 bubble_max(chip_max.x, bubble_min.y + text_size.y + pad.y * 2.0f);
|
||||
draw_list->AddRectFilled(bubble_min, bubble_max,
|
||||
ImGui::GetColorU32(color_rgb(46, 125, 80, 0.96f * alpha)), 7.0f);
|
||||
draw_list->AddRect(bubble_min, bubble_max,
|
||||
ImGui::GetColorU32(color_rgb(35, 96, 61, 0.9f * alpha)), 7.0f, 0, 1.0f);
|
||||
draw_list->AddText(ImVec2(std::floor(bubble_min.x + pad.x), std::floor(bubble_min.y + pad.y)),
|
||||
ImGui::GetColorU32(color_rgb(247, 251, 248, alpha)),
|
||||
state->route_copy_feedback_text.c_str());
|
||||
}
|
||||
|
||||
void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) {
|
||||
if (session->route_id.empty()) {
|
||||
return;
|
||||
}
|
||||
ImGui::SetNextWindowPos(anchor, ImGuiCond_Appearing);
|
||||
ImGui::SetNextWindowSizeConstraints(ImVec2(300.0f, 0.0f), ImVec2(420.0f, FLT_MAX));
|
||||
if (!ImGui::BeginPopup("##route_info_popup",
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("Route Info");
|
||||
ImGui::Separator();
|
||||
app_push_mono_font();
|
||||
ImGui::TextUnformatted(session->route_id.canonical().c_str());
|
||||
app_pop_mono_font();
|
||||
|
||||
const char *copy_icon = icon::CLIPBOARD;
|
||||
const char *link_icon = icon::BOX_ARROW_UP_RIGHT;
|
||||
const std::string useradmin_label = std::string("Useradmin ") + link_icon;
|
||||
const std::string connect_label = std::string("comma connect ") + link_icon;
|
||||
if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) {
|
||||
ImGui::SetClipboardText(session->route_id.canonical().c_str());
|
||||
state->status_text = "Copied route to clipboard";
|
||||
state->route_copy_feedback_text = "Copied";
|
||||
state->route_copy_feedback_until = ImGui::GetTime() + 1.1;
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
|
||||
ImGui::BeginTooltip();
|
||||
ImGui::TextUnformatted("Copy route");
|
||||
ImGui::EndTooltip();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) {
|
||||
open_external_url(route_useradmin_url(session->route_id));
|
||||
state->status_text = "Opened useradmin";
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) {
|
||||
open_external_url(route_connect_url(session->route_id));
|
||||
state->status_text = "Opened comma connect";
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
const int loaded_begin = session->route_id.available_begin;
|
||||
const int loaded_end = session->route_id.available_end;
|
||||
const int loaded_count = loaded_end >= loaded_begin ? (loaded_end - loaded_begin + 1) : 0;
|
||||
ImGui::Text("Duration %s", format_duration_short(session->route_data.x_max - session->route_data.x_min).c_str());
|
||||
ImGui::Text("Segments %s (%d)", session->route_id.display_slice().c_str(), loaded_count);
|
||||
ImGui::Text("Selector %s", log_selector_description(session->route_id.selector));
|
||||
if (!session->route_data.car_fingerprint.empty()) {
|
||||
ImGui::TextWrapped("Car %s", session->route_data.car_fingerprint.c_str());
|
||||
}
|
||||
if (!session->route_data.dbc_name.empty()) {
|
||||
ImGui::TextWrapped("DBC %s", session->route_data.dbc_name.c_str());
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void draw_route_id_chip(AppSession *session, UiState *state) {
|
||||
if (session->data_mode != SessionDataMode::Route || session->route_id.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGuiWindow *window = ImGui::GetCurrentWindow();
|
||||
ImDrawList *draw_list = ImGui::GetWindowDrawList();
|
||||
const RouteIdentifier &route_id = session->route_id;
|
||||
app_push_bold_font();
|
||||
const std::string dongle_text = shorten_route_part(route_id.dongle_id, 8);
|
||||
const std::string log_text = shorten_route_part(route_id.log_id, 16);
|
||||
const std::string slice_text = route_id.display_slice();
|
||||
const std::string selector_text(1, route_id.selector_char());
|
||||
const std::string sep_text = " / ";
|
||||
|
||||
const ImVec2 dongle_size = ImGui::CalcTextSize(dongle_text.c_str());
|
||||
const ImVec2 log_size = ImGui::CalcTextSize(log_text.c_str());
|
||||
const ImVec2 slice_size = state->editing_route_slice
|
||||
? ImVec2(68.0f, ImGui::GetFrameHeight())
|
||||
: ImGui::CalcTextSize(slice_text.c_str());
|
||||
const ImVec2 selector_size = ImGui::CalcTextSize(selector_text.c_str());
|
||||
const ImVec2 sep_size = ImGui::CalcTextSize(sep_text.c_str());
|
||||
constexpr float chip_pad_x = 12.0f;
|
||||
constexpr float info_size = 18.0f;
|
||||
const float chip_h = 28.0f;
|
||||
const float chip_w = chip_pad_x * 2.0f + dongle_size.x + sep_size.x + log_size.x + sep_size.x
|
||||
+ slice_size.x + sep_size.x + selector_size.x + 10.0f + info_size;
|
||||
const float menu_right = window->Pos.x + window->Size.x - 8.0f;
|
||||
const float cursor_x = ImGui::GetCursorScreenPos().x + 4.0f;
|
||||
const float chip_x = std::clamp(cursor_x, window->Pos.x + 48.0f, std::max(window->Pos.x + 48.0f, menu_right - chip_w));
|
||||
const float chip_y = std::floor(window->Pos.y + std::max(0.0f, (window->Size.y - chip_h) * 0.5f));
|
||||
const ImVec2 chip_min(chip_x, chip_y);
|
||||
const ImVec2 chip_max(chip_x + chip_w, chip_y + chip_h);
|
||||
const float text_y = std::floor(chip_y + std::max(0.0f, (chip_h - ImGui::GetTextLineHeight()) * 0.5f));
|
||||
const ImU32 chip_bg = ImGui::GetColorU32(color_rgb(247, 249, 252));
|
||||
const ImU32 chip_border = ImGui::GetColorU32(color_rgb(184, 191, 200));
|
||||
const ImU32 sep = ImGui::GetColorU32(color_rgb(162, 170, 178));
|
||||
draw_list->AddRectFilled(chip_min, chip_max, chip_bg, 0.0f);
|
||||
draw_list->AddRect(chip_min, chip_max, chip_border, 0.0f, 0, 1.0f);
|
||||
|
||||
float x = chip_x + chip_pad_x;
|
||||
const bool dongle_click = draw_route_chip_text_button(
|
||||
"##route_dongle", dongle_text, ImVec2(x, text_y), route_chip_part_color(0, true), draw_list,
|
||||
"Device identifier");
|
||||
x += dongle_size.x;
|
||||
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
|
||||
x += sep_size.x;
|
||||
const bool log_click = draw_route_chip_text_button(
|
||||
"##route_log", log_text, ImVec2(x, text_y), route_chip_part_color(1, true), draw_list,
|
||||
"Route identifier");
|
||||
x += log_size.x;
|
||||
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
|
||||
x += sep_size.x;
|
||||
|
||||
if (state->editing_route_slice) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(x - 4.0f, chip_y + 1.0f));
|
||||
ImGui::SetNextItemWidth(76.0f);
|
||||
if (state->focus_route_slice_input) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
state->focus_route_slice_input = false;
|
||||
}
|
||||
const bool applied = input_text_string("##route_slice_edit", &state->route_slice_buffer,
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
const bool deactivated = ImGui::IsItemDeactivated();
|
||||
const bool clicked_elsewhere = ImGui::IsMouseClicked(ImGuiMouseButton_Left)
|
||||
&& !ImGui::IsItemHovered()
|
||||
&& !ImGui::IsItemActive();
|
||||
if (applied) {
|
||||
if (apply_route_slice_change(session, state, state->route_slice_buffer)) {
|
||||
state->editing_route_slice = false;
|
||||
}
|
||||
} else if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
|
||||
state->editing_route_slice = false;
|
||||
} else if (deactivated || clicked_elsewhere) {
|
||||
const std::string trimmed = util::strip(state->route_slice_buffer);
|
||||
if (trimmed != route_id.display_slice()) {
|
||||
int begin = 0;
|
||||
int end = 0;
|
||||
if (parse_slice_spec(trimmed, &begin, &end)) {
|
||||
apply_route_slice_change(session, state, trimmed);
|
||||
} else {
|
||||
state->status_text = "Canceled route slice edit";
|
||||
}
|
||||
}
|
||||
state->editing_route_slice = false;
|
||||
}
|
||||
x += slice_size.x;
|
||||
} else {
|
||||
const bool slice_click = draw_route_chip_text_button(
|
||||
"##route_slice", slice_text, ImVec2(x, text_y),
|
||||
route_chip_part_color(2, route_id.slice_explicit), draw_list,
|
||||
"Segment range");
|
||||
if (slice_click) {
|
||||
state->editing_route_slice = true;
|
||||
state->focus_route_slice_input = true;
|
||||
state->route_slice_buffer = route_id.display_slice();
|
||||
}
|
||||
x += slice_size.x;
|
||||
}
|
||||
|
||||
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
|
||||
x += sep_size.x;
|
||||
const bool selector_click = draw_route_chip_text_button(
|
||||
"##route_selector", selector_text, ImVec2(x, text_y),
|
||||
route_chip_part_color(3, route_id.selector_explicit), draw_list,
|
||||
"Log selector");
|
||||
if (selector_click) {
|
||||
ImGui::OpenPopup("##route_selector_popup");
|
||||
}
|
||||
x += selector_size.x + 10.0f;
|
||||
|
||||
const ImVec2 info_center(x + info_size * 0.5f, chip_y + chip_h * 0.5f);
|
||||
ImGui::SetCursorScreenPos(ImVec2(x, chip_y + (chip_h - info_size) * 0.5f));
|
||||
ImGui::InvisibleButton("##route_info_button", ImVec2(info_size, info_size));
|
||||
const bool info_hovered = ImGui::IsItemHovered();
|
||||
if (info_hovered) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
draw_list->AddCircleFilled(info_center, info_size * 0.5f,
|
||||
ImGui::GetColorU32(info_hovered ? color_rgb(220, 229, 240) : color_rgb(239, 243, 248)));
|
||||
draw_list->AddCircle(info_center, info_size * 0.5f, chip_border, 20, 1.0f);
|
||||
const char *info_text = icon::INFO_CIRCLE;
|
||||
const ImVec2 info_text_size = ImGui::CalcTextSize(info_text);
|
||||
draw_list->AddText(ImVec2(std::floor(info_center.x - info_text_size.x * 0.5f),
|
||||
std::floor(info_center.y - info_text_size.y * 0.5f)),
|
||||
route_chip_part_color(0, true), info_text);
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
|
||||
ImGui::BeginTooltip();
|
||||
ImGui::TextUnformatted("Route details");
|
||||
ImGui::EndTooltip();
|
||||
}
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
|
||||
ImGui::OpenPopup("##route_info_popup");
|
||||
}
|
||||
|
||||
app_pop_bold_font();
|
||||
|
||||
if (dongle_click || log_click) {
|
||||
ImGui::SetClipboardText(route_id.canonical().c_str());
|
||||
state->status_text = "Copied route to clipboard";
|
||||
state->route_copy_feedback_text = "Copied";
|
||||
state->route_copy_feedback_until = ImGui::GetTime() + 1.1;
|
||||
}
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(chip_max.x - 60.0f, chip_max.y + 4.0f), ImGuiCond_Appearing);
|
||||
if (ImGui::BeginPopup("##route_selector_popup")) {
|
||||
for (LogSelector selector : {LogSelector::Auto, LogSelector::RLog, LogSelector::QLog}) {
|
||||
const bool selected = route_id.selector == selector;
|
||||
const std::string label = std::string(log_selector_name(selector)) + " " + log_selector_description(selector);
|
||||
if (ImGui::Selectable(label.c_str(), selected) && !selected) {
|
||||
apply_route_selector_change(session, state, selector);
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
draw_route_copy_feedback(state, draw_list, chip_max);
|
||||
draw_route_info_popup(session, state, ImVec2(std::max(window->Pos.x + 16.0f, chip_max.x - 360.0f), chip_max.y + 6.0f));
|
||||
}
|
||||
|
||||
std::string format_cache_bytes(uint64_t bytes) {
|
||||
if (bytes >= (1ULL << 30)) {
|
||||
return util::string_format("%.1f GiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 30));
|
||||
} else if (bytes >= (1ULL << 20)) {
|
||||
return util::string_format("%.1f MiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 20));
|
||||
} else if (bytes >= (1ULL << 10)) {
|
||||
return util::string_format("%.1f KiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 10));
|
||||
}
|
||||
return util::string_format("%llu B", static_cast<unsigned long long>(bytes));
|
||||
}
|
||||
|
||||
MapCacheStats directory_cache_stats(const fs::path &root) {
|
||||
MapCacheStats stats;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(root, ec)) {
|
||||
return stats;
|
||||
}
|
||||
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec);
|
||||
for (const fs::directory_entry &entry : it) {
|
||||
if (ec) {
|
||||
ec.clear();
|
||||
continue;
|
||||
}
|
||||
const fs::file_status status = entry.symlink_status(ec);
|
||||
if (ec || !fs::is_regular_file(status)) {
|
||||
ec.clear();
|
||||
continue;
|
||||
}
|
||||
const uintmax_t size = entry.file_size(ec);
|
||||
if (!ec) {
|
||||
stats.bytes += static_cast<uint64_t>(size);
|
||||
++stats.files;
|
||||
} else {
|
||||
ec.clear();
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
float draw_main_menu_bar(AppSession *session, UiState *state) {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(7.0f, 5.0f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(9.0f, 6.0f));
|
||||
float height = ImGui::GetFrameHeight();
|
||||
if (ImGui::BeginMainMenuBar()) {
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Undo", "Ctrl+Z", false, state->undo.can_undo())) {
|
||||
apply_undo(session, state);
|
||||
}
|
||||
if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, state->undo.can_redo())) {
|
||||
apply_redo(session, state);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Open Route...")) {
|
||||
state->open_open_route = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Stream...")) {
|
||||
state->open_stream = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Find Signal...", "Ctrl+F")) {
|
||||
state->open_find_signal = true;
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("New Layout")) {
|
||||
start_new_layout(session, state);
|
||||
}
|
||||
if (ImGui::MenuItem("Load Layout...")) {
|
||||
state->open_load_layout = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Save Layout")) {
|
||||
state->request_save_layout = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Save Layout As...")) {
|
||||
state->open_save_layout = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Reset Layout")) {
|
||||
state->request_reset_layout = true;
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Show DEPRECATED Fields", nullptr, state->show_deprecated_fields)) {
|
||||
state->show_deprecated_fields = !state->show_deprecated_fields;
|
||||
rebuild_browser_nodes(session, state);
|
||||
}
|
||||
if (ImGui::MenuItem("Show FPS", nullptr, state->show_fps_overlay)) {
|
||||
state->show_fps_overlay = !state->show_fps_overlay;
|
||||
}
|
||||
if (ImGui::MenuItem("Preferences...")) {
|
||||
state->open_preferences = true;
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Reset Plot View")) {
|
||||
reset_shared_range(state, *session);
|
||||
state->follow_latest = session->data_mode == SessionDataMode::Stream;
|
||||
clamp_shared_range(state, *session);
|
||||
state->suppress_range_side_effects = true;
|
||||
state->status_text = "Plot view reset";
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Close")) {
|
||||
state->request_close = true;
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
ImGui::SameLine(0.0f, 8.0f);
|
||||
draw_route_id_chip(session, state);
|
||||
height = ImGui::GetWindowSize().y;
|
||||
ImGui::EndMainMenuBar();
|
||||
}
|
||||
ImGui::PopStyleVar(2);
|
||||
return height;
|
||||
}
|
||||
215
tools/jotpluggler/sidebar.cc
Normal file
215
tools/jotpluggler/sidebar.cc
Normal file
@@ -0,0 +1,215 @@
|
||||
#include "tools/jotpluggler/internal.h"
|
||||
|
||||
std::string dbc_combo_label(const AppSession &session) {
|
||||
if (!session.dbc_override.empty()) return session.dbc_override;
|
||||
if (!session.route_data.dbc_name.empty()) return "Auto: " + session.route_data.dbc_name;
|
||||
return "Auto";
|
||||
}
|
||||
|
||||
float timeline_time_to_x(double time_value, double route_min, double route_max, float x_min, float x_max) {
|
||||
const double span = route_max - route_min;
|
||||
if (span <= 0.0) {
|
||||
return x_min;
|
||||
}
|
||||
const double ratio = (time_value - route_min) / span;
|
||||
return x_min + static_cast<float>(ratio * static_cast<double>(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<double>((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();
|
||||
}
|
||||
2202
tools/jotpluggler/sketch_layout.cc
Normal file
2202
tools/jotpluggler/sketch_layout.cc
Normal file
File diff suppressed because it is too large
Load Diff
207
tools/jotpluggler/stream.cc
Normal file
207
tools/jotpluggler/stream.cc
Normal file
@@ -0,0 +1,207 @@
|
||||
#include "tools/jotpluggler/internal.h"
|
||||
|
||||
#include <tuple>
|
||||
|
||||
template <typename Cmp, typename SeriesAccessor, typename LogAccessor>
|
||||
std::optional<double> stream_batch_extreme_time(const StreamExtractBatch &batch,
|
||||
Cmp cmp,
|
||||
SeriesAccessor series_time,
|
||||
LogAccessor log_time_fn) {
|
||||
std::optional<double> 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<double> 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<double> 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<TimelineEntry> *timeline, std::vector<TimelineEntry> 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<RouteSeries> new_series;
|
||||
std::vector<std::string> 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<ptrdiff_t>(old_size),
|
||||
session->route_data.logs.end(),
|
||||
[](const LogEntry &a, const LogEntry &b) {
|
||||
return a.mono_time < b.mono_time;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!batch.timeline.empty()) {
|
||||
append_stream_timeline_entries(&session->route_data.timeline, std::move(batch.timeline));
|
||||
}
|
||||
|
||||
for (CanMessageData &incoming : batch.can_messages) {
|
||||
auto it = std::lower_bound(session->route_data.can_messages.begin(),
|
||||
session->route_data.can_messages.end(),
|
||||
incoming,
|
||||
can_message_less);
|
||||
if (it == session->route_data.can_messages.end()
|
||||
|| can_message_less(incoming, *it)
|
||||
|| can_message_less(*it, incoming)) {
|
||||
session->route_data.can_messages.insert(it, std::move(incoming));
|
||||
} else {
|
||||
it->samples.insert(it->samples.end(),
|
||||
std::make_move_iterator(incoming.samples.begin()),
|
||||
std::make_move_iterator(incoming.samples.end()));
|
||||
}
|
||||
}
|
||||
|
||||
if (new_paths) {
|
||||
const size_t old_path_count = session->route_data.paths.size() - new_series.size();
|
||||
std::sort(session->route_data.paths.begin() + static_cast<ptrdiff_t>(old_path_count), session->route_data.paths.end());
|
||||
std::inplace_merge(session->route_data.paths.begin(),
|
||||
session->route_data.paths.begin() + static_cast<ptrdiff_t>(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<ptrdiff_t>(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<ptrdiff_t>(old_series_count),
|
||||
session->route_data.series.end(), series_cmp);
|
||||
session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths);
|
||||
rebuild_route_index(session);
|
||||
rebuild_browser_nodes(session, state);
|
||||
state->browser_nodes_dirty = false;
|
||||
} else {
|
||||
for (const std::string &path : touched_paths) {
|
||||
auto series_it = session->series_by_path.find(path);
|
||||
if (series_it == session->series_by_path.end() || series_it->second == nullptr) continue;
|
||||
const bool enum_like = session->route_data.enum_info.find(path) != session->route_data.enum_info.end();
|
||||
session->route_data.series_formats[path] = compute_series_format(series_it->second->values, enum_like);
|
||||
}
|
||||
}
|
||||
const std::optional<double> earliest_time = earliest_stream_batch_time(batch);
|
||||
const std::optional<double> 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);
|
||||
}
|
||||
}
|
||||
59
tools/jotpluggler/util.cc
Normal file
59
tools/jotpluggler/util.cc
Normal file
@@ -0,0 +1,59 @@
|
||||
#include "tools/jotpluggler/util.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
#include <sys/wait.h>
|
||||
|
||||
std::string read_file_or_throw(const std::filesystem::path &path) {
|
||||
const std::string contents = util::read_file(path.string());
|
||||
if (!contents.empty() || std::filesystem::exists(path)) {
|
||||
return contents;
|
||||
}
|
||||
throw std::runtime_error("Failed to read " + path.string());
|
||||
}
|
||||
|
||||
void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size) {
|
||||
ensure_parent_dir(path);
|
||||
const std::string path_string = path.string();
|
||||
const void *bytes = size == 0 ? static_cast<const void *>("") : data;
|
||||
if (util::write_file(path_string.c_str(), bytes, size, O_WRONLY | O_CREAT | O_TRUNC) != 0) {
|
||||
throw std::runtime_error("Failed to write " + path_string);
|
||||
}
|
||||
}
|
||||
|
||||
void write_file_or_throw(const std::filesystem::path &path, std::string_view contents) {
|
||||
write_file_or_throw(path, contents.data(), contents.size());
|
||||
}
|
||||
|
||||
void run_system_or_throw(const std::string &command, std::string_view action) {
|
||||
const int ret = std::system(command.c_str());
|
||||
if (ret != 0) {
|
||||
throw std::runtime_error(util::string_format("%.*s failed with exit code %d",
|
||||
static_cast<int>(action.size()), action.data(), ret));
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult run_process_capture_output(const std::vector<std::string> &args) {
|
||||
std::string command;
|
||||
for (const std::string &arg : args) {
|
||||
if (!command.empty()) command += ' ';
|
||||
command += shell_quote(arg);
|
||||
}
|
||||
command += " 2>&1";
|
||||
|
||||
FILE *pipe = popen(command.c_str(), "r");
|
||||
if (pipe == nullptr) {
|
||||
throw std::runtime_error("popen() failed");
|
||||
}
|
||||
|
||||
CommandResult result;
|
||||
std::array<char, 4096> buf = {};
|
||||
while (fgets(buf.data(), static_cast<int>(buf.size()), pipe) != nullptr) {
|
||||
result.output += buf.data();
|
||||
}
|
||||
|
||||
const int status = pclose(pipe);
|
||||
result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
|
||||
return result;
|
||||
}
|
||||
103
tools/jotpluggler/util.h
Normal file
103
tools/jotpluggler/util.h
Normal file
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/util.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
inline ImVec4 color_rgb(int r, int g, int b, float alpha = 1.0f) {
|
||||
return ImVec4(static_cast<float>(r) / 255.0f,
|
||||
static_cast<float>(g) / 255.0f,
|
||||
static_cast<float>(b) / 255.0f,
|
||||
alpha);
|
||||
}
|
||||
|
||||
inline ImVec4 color_rgb(const std::array<uint8_t, 3> &color, float alpha = 1.0f) {
|
||||
return color_rgb(color[0], color[1], color[2], alpha);
|
||||
}
|
||||
|
||||
inline std::string lowercase_copy(std::string_view value) {
|
||||
std::string out(value);
|
||||
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(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<std::string *>(data->UserData);
|
||||
text->resize(static_cast<size_t>(data->BufTextLen));
|
||||
data->Buf = text->data();
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool input_text_string(const char *label,
|
||||
std::string *text,
|
||||
ImGuiInputTextFlags flags = 0) {
|
||||
flags |= ImGuiInputTextFlags_CallbackResize;
|
||||
return ImGui::InputText(label, text->data(), text->capacity() + 1,
|
||||
flags, imgui_resize_callback, text);
|
||||
}
|
||||
|
||||
inline bool input_text_with_hint_string(const char *label,
|
||||
const char *hint,
|
||||
std::string *text,
|
||||
ImGuiInputTextFlags flags = 0) {
|
||||
flags |= ImGuiInputTextFlags_CallbackResize;
|
||||
return ImGui::InputTextWithHint(label, hint, text->data(), text->capacity() + 1,
|
||||
flags, imgui_resize_callback, text);
|
||||
}
|
||||
|
||||
inline bool input_text_multiline_string(const char *label,
|
||||
std::string *text,
|
||||
const ImVec2 &size = ImVec2(0.0f, 0.0f),
|
||||
ImGuiInputTextFlags flags = 0) {
|
||||
flags |= ImGuiInputTextFlags_CallbackResize;
|
||||
return ImGui::InputTextMultiline(label, text->data(), text->capacity() + 1,
|
||||
size, flags, imgui_resize_callback, text);
|
||||
}
|
||||
|
||||
inline bool is_local_stream_address(std::string_view address) {
|
||||
return address.empty() || address == "127.0.0.1" || address == "localhost";
|
||||
}
|
||||
|
||||
inline void ensure_parent_dir(const std::filesystem::path &path) {
|
||||
if (path.has_parent_path()) {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
}
|
||||
}
|
||||
|
||||
inline std::string shell_quote(std::string_view value) {
|
||||
std::string quoted;
|
||||
quoted.reserve(value.size() + 8);
|
||||
quoted.push_back('\'');
|
||||
for (char c : value) {
|
||||
if (c == '\'') {
|
||||
quoted += "'\\''";
|
||||
} else {
|
||||
quoted.push_back(c);
|
||||
}
|
||||
}
|
||||
quoted.push_back('\'');
|
||||
return quoted;
|
||||
}
|
||||
|
||||
struct CommandResult {
|
||||
int exit_code = 0;
|
||||
std::string output;
|
||||
};
|
||||
|
||||
std::string read_file_or_throw(const std::filesystem::path &path);
|
||||
void write_file_or_throw(const std::filesystem::path &path, std::string_view contents);
|
||||
void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size);
|
||||
void run_system_or_throw(const std::string &command, std::string_view action);
|
||||
CommandResult run_process_capture_output(const std::vector<std::string> &args);
|
||||
@@ -1,31 +1,68 @@
|
||||
#include "tools/replay/logreader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <utility>
|
||||
#include "tools/replay/filereader.h"
|
||||
#include "tools/replay/py_downloader.h"
|
||||
#include "tools/replay/util.h"
|
||||
#include "common/util.h"
|
||||
|
||||
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache) {
|
||||
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache,
|
||||
const ProgressCallback &progress) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
compressed_size_ = 0;
|
||||
decompressed_size_ = 0;
|
||||
download_seconds_ = 0.0;
|
||||
decompress_seconds_ = 0.0;
|
||||
parse_seconds_ = 0.0;
|
||||
|
||||
if (progress) {
|
||||
installDownloadProgressHandler([progress](uint64_t cur, uint64_t total, bool success) {
|
||||
if (success) {
|
||||
progress(ProgressStage::Downloading, cur, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
const auto download_start = Clock::now();
|
||||
std::string data = FileReader(local_cache).read(url, abort);
|
||||
const auto download_end = Clock::now();
|
||||
if (progress) {
|
||||
installDownloadProgressHandler(nullptr);
|
||||
}
|
||||
compressed_size_ = data.size();
|
||||
download_seconds_ = std::chrono::duration<double>(download_end - download_start).count();
|
||||
if (!data.empty()) {
|
||||
const auto decompress_start = Clock::now();
|
||||
if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) {
|
||||
data = decompressBZ2(data, abort);
|
||||
} else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
|
||||
data = decompressZST(data, abort);
|
||||
}
|
||||
const auto decompress_end = Clock::now();
|
||||
decompress_seconds_ = std::chrono::duration<double>(decompress_end - decompress_start).count();
|
||||
}
|
||||
decompressed_size_ = data.size();
|
||||
|
||||
bool success = !data.empty() && load(data.data(), data.size(), abort);
|
||||
bool success = !data.empty() && load(data.data(), data.size(), abort, progress);
|
||||
if (filters_.empty())
|
||||
raw_ = std::move(data);
|
||||
return success;
|
||||
}
|
||||
|
||||
bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort) {
|
||||
bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort,
|
||||
const ProgressCallback &progress) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
const auto parse_start = Clock::now();
|
||||
try {
|
||||
events.reserve(65000);
|
||||
kj::ArrayPtr<const capnp::word> words((const capnp::word *)data, size / sizeof(capnp::word));
|
||||
const uint64_t total_bytes = size;
|
||||
const uint64_t report_step = std::max<uint64_t>(1, total_bytes / 200);
|
||||
uint64_t last_reported = 0;
|
||||
if (progress) {
|
||||
progress(ProgressStage::Parsing, 0, total_bytes);
|
||||
}
|
||||
while (words.size() > 0 && !(abort && *abort)) {
|
||||
capnp::FlatArrayMessageReader reader(words);
|
||||
auto event = reader.getRoot<cereal::Event>();
|
||||
@@ -56,15 +93,30 @@ bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort) {
|
||||
events.emplace_back(which, sof ? sof : mono_time, event_data, idx.getSegmentNum());
|
||||
}
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
const uint64_t current_bytes =
|
||||
total_bytes - static_cast<uint64_t>(words.size() * sizeof(capnp::word));
|
||||
if (current_bytes >= total_bytes || current_bytes - last_reported >= report_step) {
|
||||
progress(ProgressStage::Parsing, current_bytes, total_bytes);
|
||||
last_reported = current_bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (const kj::Exception &e) {
|
||||
rWarning("Failed to parse log : %s.\nRetrieved %zu events from corrupt log", e.getDescription().cStr(), events.size());
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
progress(ProgressStage::Parsing, size, size);
|
||||
}
|
||||
|
||||
if (requires_migration) {
|
||||
migrateOldEvents();
|
||||
}
|
||||
|
||||
parse_seconds_ = std::chrono::duration<double>(Clock::now() - parse_start).count();
|
||||
|
||||
if (!events.empty() && !(abort && *abort)) {
|
||||
events.shrink_to_fit();
|
||||
std::sort(events.begin(), events.end());
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -26,12 +28,26 @@ public:
|
||||
|
||||
class LogReader {
|
||||
public:
|
||||
enum class ProgressStage {
|
||||
Downloading,
|
||||
Parsing,
|
||||
};
|
||||
|
||||
using ProgressCallback = std::function<void(ProgressStage stage, uint64_t current, uint64_t total)>;
|
||||
|
||||
LogReader(const std::vector<bool> &filters = {}) { filters_ = filters; }
|
||||
bool load(const std::string &url, std::atomic<bool> *abort = nullptr,
|
||||
bool local_cache = false);
|
||||
bool load(const char *data, size_t size, std::atomic<bool> *abort = nullptr);
|
||||
bool local_cache = false, const ProgressCallback &progress = {});
|
||||
bool load(const char *data, size_t size, std::atomic<bool> *abort = nullptr,
|
||||
const ProgressCallback &progress = {});
|
||||
std::vector<Event> events;
|
||||
|
||||
uint64_t compressed_size() const { return compressed_size_; }
|
||||
uint64_t decompressed_size() const { return decompressed_size_; }
|
||||
double download_seconds() const { return download_seconds_; }
|
||||
double decompress_seconds() const { return decompress_seconds_; }
|
||||
double parse_seconds() const { return parse_seconds_; }
|
||||
|
||||
private:
|
||||
void migrateOldEvents();
|
||||
|
||||
@@ -39,4 +55,9 @@ private:
|
||||
bool requires_migration = true;
|
||||
std::vector<bool> filters_;
|
||||
MonotonicBuffer buffer_{1024 * 1024};
|
||||
uint64_t compressed_size_ = 0;
|
||||
uint64_t decompressed_size_ = 0;
|
||||
double download_seconds_ = 0.0;
|
||||
double decompress_seconds_ = 0.0;
|
||||
double parse_seconds_ = 0.0;
|
||||
};
|
||||
|
||||
@@ -149,11 +149,16 @@ std::string runPython(const std::vector<std::string> &args, std::atomic<bool> *a
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
|
||||
bool failed = (abort && *abort) ||
|
||||
const bool aborted = abort && *abort;
|
||||
const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM;
|
||||
bool failed = aborted ||
|
||||
(WIFEXITED(status) && WEXITSTATUS(status) != 0) ||
|
||||
WIFSIGNALED(status);
|
||||
if (failed) {
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
|
||||
if (expected_sigterm) {
|
||||
// Route/camera teardown cancels outstanding downloader subprocesses.
|
||||
// Keep that expected shutdown path quiet.
|
||||
} else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
|
||||
rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status));
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
rWarning("py_downloader: process killed by signal %d", WTERMSIG(status));
|
||||
|
||||
Reference in New Issue
Block a user