From 42d9bd05162505b3975ab3eaec54d1b658db27e2 Mon Sep 17 00:00:00 2001 From: Jimmy <9859727+Quantizr@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:36:50 -1000 Subject: [PATCH] jotpluggler: sync x axes and autofit y axis (#36143) * sync x axes of all timeseries plots * always autofit y-axis * fix typing --- tools/jotpluggler/pluggle.py | 31 ++++++++++++++++++ tools/jotpluggler/views.py | 63 +++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py index 582a44454e..57ba2a245f 100755 --- a/tools/jotpluggler/pluggle.py +++ b/tools/jotpluggler/pluggle.py @@ -65,6 +65,10 @@ class PlaybackManager: self.current_time_s = 0.0 self.duration_s = 0.0 + self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time) + self.x_axis_observers = [] # callbacks for x-axis changes + self._updating_x_axis = False + def set_route_duration(self, duration: float): self.duration_s = duration self.seek(min(self.current_time_s, duration)) @@ -87,6 +91,33 @@ class PlaybackManager: dpg.configure_item("play_pause_button", texture_tag="play_texture") return self.current_time_s + def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None): + if self._updating_x_axis: + return + + new_bounds = (min_time, max_time) + if new_bounds == self.x_axis_bounds: + return + + self.x_axis_bounds = new_bounds + self._updating_x_axis = True # prevent recursive updates + + try: + for callback in self.x_axis_observers: + try: + callback(min_time, max_time, source_panel) + except Exception as e: + print(f"Error in x-axis sync callback: {e}") + finally: + self._updating_x_axis = False + + def add_x_axis_observer(self, callback): + if callback not in self.x_axis_observers: + self.x_axis_observers.append(callback) + + def remove_x_axis_observer(self, callback): + if callback in self.x_axis_observers: + self.x_axis_observers.remove(callback) class MainController: def __init__(self, scale: float = 1.0): diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py index 4af9a102ac..f3da6e3e6f 100644 --- a/tools/jotpluggler/views.py +++ b/tools/jotpluggler/views.py @@ -51,9 +51,11 @@ class TimeSeriesPanel(ViewPanel): self._update_lock = threading.RLock() self._results_deque: deque[tuple[str, list, list]] = deque() self._new_data = False + self._last_x_limits = (0.0, 0.0) def create_ui(self, parent_tag: str): self.data_manager.add_observer(self.on_data_loaded) + self.playback_manager.add_x_axis_observer(self._on_x_axis_sync) with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): dpg.add_plot_legend() dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag) @@ -70,8 +72,20 @@ class TimeSeriesPanel(ViewPanel): if not self._ui_created: return + current_limits = dpg.get_axis_limits(self.x_axis_tag) + # downsample if plot zoom changed significantly + plot_duration = current_limits[1] - current_limits[0] + if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: + self._downsample_all_series(plot_duration) + # sync x-axis if changed by user + if self._last_x_limits != current_limits: + self.playback_manager.set_x_axis_bounds(current_limits[0], current_limits[1], source_panel=self) + self._last_x_limits = current_limits + self._fit_y_axis(current_limits[0], current_limits[1]) + if self._new_data: # handle new data in main thread self._new_data = False + dpg.set_axis_limits_constraints(self.x_axis_tag, -10, (self.playback_manager.duration_s + 10)) for series_path in list(self._series_data.keys()): self.add_series(series_path, update=True) @@ -96,10 +110,50 @@ class TimeSeriesPanel(ViewPanel): if dpg.does_item_exist(series_tag): dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}") - # downsample if plot zoom changed significantly - plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] - if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: - self._downsample_all_series(plot_duration) + def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel): + with self._update_lock: + if source_panel == self or not self._ui_created: + return + dpg.set_axis_limits(self.x_axis_tag, min_time, max_time) + dpg.render_dearpygui_frame() + dpg.set_axis_limits_auto(self.x_axis_tag) + self._last_x_limits = (min_time, max_time) + self._fit_y_axis(min_time, max_time) + + def _fit_y_axis(self, x_min: float, x_max: float): + if not self._series_data: + dpg.set_axis_limits(self.y_axis_tag, -1, 1) + return + + global_min = float('inf') + global_max = float('-inf') + found_data = False + + for time_array, value_array in self._series_data.values(): + if len(time_array) == 0: + continue + start_idx, end_idx = np.searchsorted(time_array, [x_min, x_max]) + end_idx = min(end_idx, len(time_array) - 1) + if start_idx <= end_idx: + y_slice = value_array[start_idx:end_idx + 1] + series_min, series_max = np.min(y_slice), np.max(y_slice) + global_min = min(global_min, series_min) + global_max = max(global_max, series_max) + found_data = True + + if not found_data: + dpg.set_axis_limits(self.y_axis_tag, -1, 1) + return + + if global_min == global_max: + padding = max(abs(global_min) * 0.1, 1.0) + y_min, y_max = global_min - padding, global_max + padding + else: + range_size = global_max - global_min + padding = range_size * 0.1 + y_min, y_max = global_min - padding, global_max + padding + + dpg.set_axis_limits(self.y_axis_tag, y_min, y_max) def _downsample_all_series(self, plot_duration): plot_width = dpg.get_item_rect_size(self.plot_tag)[0] @@ -145,6 +199,7 @@ class TimeSeriesPanel(ViewPanel): def destroy_ui(self): with self._update_lock: self.data_manager.remove_observer(self.on_data_loaded) + self.playback_manager.remove_x_axis_observer(self._on_x_axis_sync) if dpg.does_item_exist(self.plot_tag): dpg.delete_item(self.plot_tag) self._ui_created = False