You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							315 lines
						
					
					
						
							14 KiB
						
					
					
				
			
		
		
	
	
							315 lines
						
					
					
						
							14 KiB
						
					
					
				import os
 | 
						|
import re
 | 
						|
import threading
 | 
						|
import numpy as np
 | 
						|
import dearpygui.dearpygui as dpg
 | 
						|
 | 
						|
 | 
						|
class DataTreeNode:
 | 
						|
  def __init__(self, name: str, full_path: str = "", parent=None):
 | 
						|
    self.name = name
 | 
						|
    self.full_path = full_path
 | 
						|
    self.parent = parent
 | 
						|
    self.children: dict[str, DataTreeNode] = {}
 | 
						|
    self.filtered_children: dict[str, DataTreeNode] = {}
 | 
						|
    self.created_children: dict[str, DataTreeNode] = {}
 | 
						|
    self.is_leaf = False
 | 
						|
    self.is_plottable: bool | None = None
 | 
						|
    self.ui_created = False
 | 
						|
    self.children_ui_created = False
 | 
						|
    self.ui_tag: str | None = None
 | 
						|
 | 
						|
 | 
						|
class DataTree:
 | 
						|
  MAX_NODES_PER_FRAME = 50
 | 
						|
 | 
						|
  def __init__(self, data_manager, playback_manager):
 | 
						|
    self.data_manager = data_manager
 | 
						|
    self.playback_manager = playback_manager
 | 
						|
    self.current_search = ""
 | 
						|
    self.data_tree = DataTreeNode(name="root")
 | 
						|
    self._build_queue: dict[str, tuple[DataTreeNode, DataTreeNode, str | int]] = {} # full_path -> (node, parent, before_tag)
 | 
						|
    self._current_created_paths: set[str] = set()
 | 
						|
    self._current_filtered_paths: set[str] = set()
 | 
						|
    self._path_to_node: dict[str, DataTreeNode] = {}  # full_path -> node
 | 
						|
    self._expanded_tags: set[str] = set()
 | 
						|
    self._item_handlers: dict[str, str] = {}  # ui_tag -> handler_tag
 | 
						|
    self._char_width = None
 | 
						|
    self._queued_search = None
 | 
						|
    self._new_data = False
 | 
						|
    self._ui_lock = threading.RLock()
 | 
						|
    self._handlers_to_delete = []
 | 
						|
    self.data_manager.add_observer(self._on_data_loaded)
 | 
						|
 | 
						|
  def create_ui(self, parent_tag: str):
 | 
						|
    with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1):
 | 
						|
      dpg.add_text("Timeseries List")
 | 
						|
      dpg.add_separator()
 | 
						|
      dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data)
 | 
						|
      dpg.add_separator()
 | 
						|
      with dpg.child_window(border=False, width=-1, height=-1):
 | 
						|
        with dpg.group(tag="data_tree_container"):
 | 
						|
          pass
 | 
						|
 | 
						|
  def _on_data_loaded(self, data: dict):
 | 
						|
    with self._ui_lock:
 | 
						|
      if data.get('segment_added') or data.get('reset'):
 | 
						|
        self._new_data = True
 | 
						|
 | 
						|
  def update_frame(self, font):
 | 
						|
    if self._handlers_to_delete:  # we need to do everything in main thread, frame callbacks are flaky
 | 
						|
      dpg.render_dearpygui_frame()  # wait a frame to ensure queued callbacks are done
 | 
						|
      with self._ui_lock:
 | 
						|
        for handler in self._handlers_to_delete:
 | 
						|
          dpg.delete_item(handler)
 | 
						|
        self._handlers_to_delete.clear()
 | 
						|
 | 
						|
    with self._ui_lock:
 | 
						|
      if self._char_width is None:
 | 
						|
        if size := dpg.get_text_size(" ", font=font):
 | 
						|
          self._char_width = size[0] / 2 # we scale font 2x and downscale to fix hidpi bug
 | 
						|
 | 
						|
      if self._new_data:
 | 
						|
        self._process_path_change()
 | 
						|
        self._new_data = False
 | 
						|
        return
 | 
						|
 | 
						|
      if self._queued_search is not None:
 | 
						|
        self.current_search = self._queued_search
 | 
						|
        self._process_path_change()
 | 
						|
        self._queued_search = None
 | 
						|
        return
 | 
						|
 | 
						|
      nodes_processed = 0
 | 
						|
      while self._build_queue and nodes_processed < self.MAX_NODES_PER_FRAME:
 | 
						|
        child_node, parent, before_tag = self._build_queue.pop(next(iter(self._build_queue)))
 | 
						|
        parent_tag = "data_tree_container" if parent.name == "root" else parent.ui_tag
 | 
						|
        if not child_node.ui_created:
 | 
						|
          if child_node.is_leaf:
 | 
						|
            self._create_leaf_ui(child_node, parent_tag, before_tag)
 | 
						|
          else:
 | 
						|
            self._create_tree_node_ui(child_node, parent_tag, before_tag)
 | 
						|
        parent.created_children[child_node.name] = parent.children[child_node.name]
 | 
						|
        self._current_created_paths.add(child_node.full_path)
 | 
						|
        nodes_processed += 1
 | 
						|
 | 
						|
  def _process_path_change(self):
 | 
						|
    self._build_queue.clear()
 | 
						|
    search_term = self.current_search.strip().lower()
 | 
						|
    all_paths = set(self.data_manager.get_all_paths())
 | 
						|
    new_filtered_leafs = {path for path in all_paths if self._should_show_path(path, search_term)}
 | 
						|
    new_filtered_paths = set(new_filtered_leafs)
 | 
						|
    for path in new_filtered_leafs:
 | 
						|
      parts = path.split('/')
 | 
						|
      for i in range(1, len(parts)):
 | 
						|
        prefix = '/'.join(parts[:i])
 | 
						|
        new_filtered_paths.add(prefix)
 | 
						|
    created_paths_to_remove = self._current_created_paths - new_filtered_paths
 | 
						|
    filtered_paths_to_remove = self._current_filtered_paths - new_filtered_leafs
 | 
						|
 | 
						|
    if created_paths_to_remove or filtered_paths_to_remove:
 | 
						|
      self._remove_paths_from_tree(created_paths_to_remove, filtered_paths_to_remove)
 | 
						|
      self._apply_expansion_to_tree(self.data_tree, search_term)
 | 
						|
 | 
						|
    paths_to_add = new_filtered_leafs - self._current_created_paths
 | 
						|
    if paths_to_add:
 | 
						|
      self._add_paths_to_tree(paths_to_add)
 | 
						|
      self._apply_expansion_to_tree(self.data_tree, search_term)
 | 
						|
    self._current_filtered_paths = new_filtered_paths
 | 
						|
 | 
						|
  def _remove_paths_from_tree(self, created_paths_to_remove, filtered_paths_to_remove):
 | 
						|
    for path in sorted(created_paths_to_remove, reverse=True):
 | 
						|
      current_node = self._path_to_node[path]
 | 
						|
 | 
						|
      if len(current_node.created_children) == 0:
 | 
						|
        self._current_created_paths.remove(current_node.full_path)
 | 
						|
        if item_handler_tag := self._item_handlers.get(current_node.ui_tag):
 | 
						|
          dpg.configure_item(item_handler_tag, show=False)
 | 
						|
          self._handlers_to_delete.append(item_handler_tag)
 | 
						|
          del self._item_handlers[current_node.ui_tag]
 | 
						|
        dpg.delete_item(current_node.ui_tag)
 | 
						|
        current_node.ui_created = False
 | 
						|
        current_node.ui_tag = None
 | 
						|
        current_node.children_ui_created = False
 | 
						|
        del current_node.parent.created_children[current_node.name]
 | 
						|
        del current_node.parent.filtered_children[current_node.name]
 | 
						|
 | 
						|
    for path in filtered_paths_to_remove:
 | 
						|
      parts = path.split('/')
 | 
						|
      current_node = self._path_to_node[path]
 | 
						|
 | 
						|
      part_array_index = -1
 | 
						|
      while len(current_node.filtered_children) == 0 and part_array_index >= -len(parts):
 | 
						|
        current_node = current_node.parent
 | 
						|
        if parts[part_array_index] in current_node.filtered_children:
 | 
						|
          del current_node.filtered_children[parts[part_array_index]]
 | 
						|
        part_array_index -= 1
 | 
						|
 | 
						|
  def _add_paths_to_tree(self, paths):
 | 
						|
    parent_nodes_to_recheck = set()
 | 
						|
    for path in sorted(paths):
 | 
						|
      parts = path.split('/')
 | 
						|
      current_node = self.data_tree
 | 
						|
      current_path_prefix = ""
 | 
						|
 | 
						|
      for i, part in enumerate(parts):
 | 
						|
        current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part
 | 
						|
        if i < len(parts):
 | 
						|
          parent_nodes_to_recheck.add(current_node)  # for incremental changes from new data
 | 
						|
        if part not in current_node.children:
 | 
						|
          current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node)
 | 
						|
          self._path_to_node[current_path_prefix] = current_node.children[part]
 | 
						|
        current_node.filtered_children[part] = current_node.children[part]
 | 
						|
        current_node = current_node.children[part]
 | 
						|
 | 
						|
      if not current_node.is_leaf:
 | 
						|
        current_node.is_leaf = True
 | 
						|
 | 
						|
    for p_node in parent_nodes_to_recheck:
 | 
						|
      p_node.children_ui_created = False
 | 
						|
      self._request_children_build(p_node)
 | 
						|
 | 
						|
  def _get_node_label_and_expand(self, node: DataTreeNode, search_term: str):
 | 
						|
    label = f"{node.name} ({len(node.filtered_children)} fields)"
 | 
						|
    expand = len(search_term) > 0 and any(search_term in path for path in self._get_descendant_paths(node))
 | 
						|
    if expand and node.parent and len(node.parent.filtered_children) > 100 and len(node.filtered_children) > 2:
 | 
						|
      label += " (+)"  # symbol for large lists which aren't fully expanded for performance (only affects procLog rn)
 | 
						|
      expand = False
 | 
						|
    return label, expand
 | 
						|
 | 
						|
  def _apply_expansion_to_tree(self, node: DataTreeNode, search_term: str):
 | 
						|
    if node.ui_created and not node.is_leaf and node.ui_tag and dpg.does_item_exist(node.ui_tag):
 | 
						|
      label, expand = self._get_node_label_and_expand(node, search_term)
 | 
						|
      if expand:
 | 
						|
        self._expanded_tags.add(node.ui_tag)
 | 
						|
        dpg.set_value(node.ui_tag, expand)
 | 
						|
      elif node.ui_tag in self._expanded_tags:  # not expanded and was expanded
 | 
						|
        self._expanded_tags.remove(node.ui_tag)
 | 
						|
        dpg.set_value(node.ui_tag, expand)
 | 
						|
        dpg.delete_item(node.ui_tag, children_only=True)  # delete children (not visible since collapsed)
 | 
						|
        self._reset_ui_state_recursive(node)
 | 
						|
        node.children_ui_created = False
 | 
						|
      dpg.set_item_label(node.ui_tag, label)
 | 
						|
    for child in node.created_children.values():
 | 
						|
      self._apply_expansion_to_tree(child, search_term)
 | 
						|
 | 
						|
  def _reset_ui_state_recursive(self, node: DataTreeNode):
 | 
						|
    for child in node.created_children.values():
 | 
						|
      if child.ui_tag is not None:
 | 
						|
        if item_handler_tag := self._item_handlers.get(child.ui_tag):
 | 
						|
          self._handlers_to_delete.append(item_handler_tag)
 | 
						|
          dpg.configure_item(item_handler_tag, show=False)
 | 
						|
          del self._item_handlers[child.ui_tag]
 | 
						|
        self._reset_ui_state_recursive(child)
 | 
						|
        child.ui_created = False
 | 
						|
        child.ui_tag = None
 | 
						|
        child.children_ui_created = False
 | 
						|
        self._current_created_paths.remove(child.full_path)
 | 
						|
    node.created_children.clear()
 | 
						|
 | 
						|
  def search_data(self):
 | 
						|
    with self._ui_lock:
 | 
						|
      self._queued_search = dpg.get_value("search_input")
 | 
						|
 | 
						|
  def _create_tree_node_ui(self, node: DataTreeNode, parent_tag: str, before: str | int):
 | 
						|
    node.ui_tag = f"tree_{node.full_path}"
 | 
						|
    search_term = self.current_search.strip().lower()
 | 
						|
    label, expand = self._get_node_label_and_expand(node, search_term)
 | 
						|
    if expand:
 | 
						|
      self._expanded_tags.add(node.ui_tag)
 | 
						|
    elif node.ui_tag in self._expanded_tags:
 | 
						|
      self._expanded_tags.remove(node.ui_tag)
 | 
						|
 | 
						|
    with dpg.tree_node(
 | 
						|
      label=label, parent=parent_tag, tag=node.ui_tag, default_open=expand, open_on_arrow=True, open_on_double_click=True, before=before, delay_search=True
 | 
						|
    ):
 | 
						|
      with dpg.item_handler_registry() as handler_tag:
 | 
						|
        dpg.add_item_toggled_open_handler(callback=lambda s, a, u: self._request_children_build(node))
 | 
						|
        dpg.add_item_visible_handler(callback=lambda s, a, u: self._request_children_build(node))
 | 
						|
      dpg.bind_item_handler_registry(node.ui_tag, handler_tag)
 | 
						|
      self._item_handlers[node.ui_tag] = handler_tag
 | 
						|
    node.ui_created = True
 | 
						|
 | 
						|
  def _create_leaf_ui(self, node: DataTreeNode, parent_tag: str, before: str | int):
 | 
						|
    node.ui_tag = f"leaf_{node.full_path}"
 | 
						|
    with dpg.group(parent=parent_tag, tag=node.ui_tag, before=before, delay_search=True):
 | 
						|
      with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp, delay_search=True):
 | 
						|
        dpg.add_table_column(init_width_or_weight=0.5)
 | 
						|
        dpg.add_table_column(init_width_or_weight=0.5)
 | 
						|
        with dpg.table_row():
 | 
						|
          dpg.add_text(node.name)
 | 
						|
          dpg.add_text("N/A", tag=f"value_{node.full_path}")
 | 
						|
 | 
						|
    if node.is_plottable is None:
 | 
						|
      node.is_plottable = self.data_manager.is_plottable(node.full_path)
 | 
						|
    if node.is_plottable:
 | 
						|
      with dpg.drag_payload(parent=node.ui_tag, drag_data=node.full_path, payload_type="TIMESERIES_PAYLOAD"):
 | 
						|
        dpg.add_text(f"Plot: {node.full_path}")
 | 
						|
 | 
						|
    with dpg.item_handler_registry() as handler_tag:
 | 
						|
      dpg.add_item_visible_handler(callback=self._on_item_visible, user_data=node.full_path)
 | 
						|
    dpg.bind_item_handler_registry(node.ui_tag, handler_tag)
 | 
						|
    self._item_handlers[node.ui_tag] = handler_tag
 | 
						|
    node.ui_created = True
 | 
						|
 | 
						|
  def _on_item_visible(self, sender, app_data, user_data):
 | 
						|
    with self._ui_lock:
 | 
						|
      path = user_data
 | 
						|
      value_tag = f"value_{path}"
 | 
						|
      if not dpg.does_item_exist(value_tag):
 | 
						|
        return
 | 
						|
      value_column_width = dpg.get_item_rect_size(f"leaf_{path}")[0] // 2
 | 
						|
      value = self.data_manager.get_value_at(path, self.playback_manager.current_time_s)
 | 
						|
      if value is not None:
 | 
						|
        formatted_value = self.format_and_truncate(value, value_column_width, self._char_width)
 | 
						|
        dpg.set_value(value_tag, formatted_value)
 | 
						|
      else:
 | 
						|
        dpg.set_value(value_tag, "N/A")
 | 
						|
 | 
						|
  def _request_children_build(self, node: DataTreeNode):
 | 
						|
    with self._ui_lock:
 | 
						|
      if not node.children_ui_created and (node.name == "root" or (node.ui_tag is not None and dpg.get_value(node.ui_tag))):  # check root or node expanded
 | 
						|
        sorted_children = sorted(node.filtered_children.values(), key=self._natural_sort_key)
 | 
						|
        next_existing: list[int | str] = [0] * len(sorted_children)
 | 
						|
        current_before_tag: int | str = 0
 | 
						|
 | 
						|
        for i in range(len(sorted_children) - 1, -1, -1):  # calculate "before_tag" for correct ordering when incrementally building tree
 | 
						|
          child = sorted_children[i]
 | 
						|
          next_existing[i] = current_before_tag
 | 
						|
          if child.ui_created:
 | 
						|
            candidate_tag = f"leaf_{child.full_path}" if child.is_leaf else f"tree_{child.full_path}"
 | 
						|
            if dpg.does_item_exist(candidate_tag):
 | 
						|
              current_before_tag = candidate_tag
 | 
						|
 | 
						|
        for i, child_node in enumerate(sorted_children):
 | 
						|
          if not child_node.ui_created:
 | 
						|
            before_tag = next_existing[i]
 | 
						|
            self._build_queue[child_node.full_path] = (child_node, node, before_tag)
 | 
						|
        node.children_ui_created = True
 | 
						|
 | 
						|
  def _should_show_path(self, path: str, search_term: str) -> bool:
 | 
						|
    if 'DEPRECATED' in path and not os.environ.get('SHOW_DEPRECATED'):
 | 
						|
      return False
 | 
						|
    return not search_term or search_term in path.lower()
 | 
						|
 | 
						|
  def _natural_sort_key(self, node: DataTreeNode):
 | 
						|
    node_type_key = node.is_leaf
 | 
						|
    parts = [int(p) if p.isdigit() else p.lower() for p in re.split(r'(\d+)', node.name) if p]
 | 
						|
    return (node_type_key, parts)
 | 
						|
 | 
						|
  def _get_descendant_paths(self, node: DataTreeNode):
 | 
						|
    for child_name, child_node in node.filtered_children.items():
 | 
						|
      child_name_lower = child_name.lower()
 | 
						|
      if child_node.is_leaf:
 | 
						|
        yield child_name_lower
 | 
						|
      else:
 | 
						|
        for path in self._get_descendant_paths(child_node):
 | 
						|
          yield f"{child_name_lower}/{path}"
 | 
						|
 | 
						|
  @staticmethod
 | 
						|
  def format_and_truncate(value, available_width: float, char_width: float) -> str:
 | 
						|
    s = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value)
 | 
						|
    max_chars = int(available_width / char_width)
 | 
						|
    if len(s) > max_chars:
 | 
						|
      return s[: max(0, max_chars - 3)] + "..."
 | 
						|
    return s
 | 
						|
 |