jotpluggler: sync x axes and autofit y axis (#36143)

* sync x axes of all timeseries plots

* always autofit y-axis

* fix typing
pull/36146/head
Jimmy 3 days ago committed by GitHub
parent 3ca9f351a0
commit 42d9bd0516
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 31
      tools/jotpluggler/pluggle.py
  2. 63
      tools/jotpluggler/views.py

@ -65,6 +65,10 @@ class PlaybackManager:
self.current_time_s = 0.0 self.current_time_s = 0.0
self.duration_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): def set_route_duration(self, duration: float):
self.duration_s = duration self.duration_s = duration
self.seek(min(self.current_time_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") dpg.configure_item("play_pause_button", texture_tag="play_texture")
return self.current_time_s 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: class MainController:
def __init__(self, scale: float = 1.0): def __init__(self, scale: float = 1.0):

@ -51,9 +51,11 @@ class TimeSeriesPanel(ViewPanel):
self._update_lock = threading.RLock() self._update_lock = threading.RLock()
self._results_deque: deque[tuple[str, list, list]] = deque() self._results_deque: deque[tuple[str, list, list]] = deque()
self._new_data = False self._new_data = False
self._last_x_limits = (0.0, 0.0)
def create_ui(self, parent_tag: str): def create_ui(self, parent_tag: str):
self.data_manager.add_observer(self.on_data_loaded) 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"): 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_legend()
dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag) 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: if not self._ui_created:
return 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 if self._new_data: # handle new data in main thread
self._new_data = False 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()): for series_path in list(self._series_data.keys()):
self.add_series(series_path, update=True) self.add_series(series_path, update=True)
@ -96,10 +110,50 @@ class TimeSeriesPanel(ViewPanel):
if dpg.does_item_exist(series_tag): if dpg.does_item_exist(series_tag):
dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}") dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}")
# downsample if plot zoom changed significantly def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel):
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] with self._update_lock:
if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: if source_panel == self or not self._ui_created:
self._downsample_all_series(plot_duration) 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): def _downsample_all_series(self, plot_duration):
plot_width = dpg.get_item_rect_size(self.plot_tag)[0] plot_width = dpg.get_item_rect_size(self.plot_tag)[0]
@ -145,6 +199,7 @@ class TimeSeriesPanel(ViewPanel):
def destroy_ui(self): def destroy_ui(self):
with self._update_lock: with self._update_lock:
self.data_manager.remove_observer(self.on_data_loaded) 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): if dpg.does_item_exist(self.plot_tag):
dpg.delete_item(self.plot_tag) dpg.delete_item(self.plot_tag)
self._ui_created = False self._ui_created = False

Loading…
Cancel
Save