|  |  |  | @ -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 | 
			
		
	
	
		
			
				
					|  |  |  | 
 |