jotpluggler!

pull/36045/head
Quantizr (Jimmy) 4 weeks ago
parent a5044302a2
commit 942f4a3fcf
  1. 1
      pyproject.toml
  2. 179
      tools/jotpluggler/data.py
  3. 304
      tools/jotpluggler/layout.py
  4. 240
      tools/jotpluggler/pluggle.py
  5. 308
      tools/jotpluggler/views.py
  6. 83
      uv.lock

@ -121,6 +121,7 @@ dev = [
tools = [
"metadrive-simulator @ https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl ; (platform_machine != 'aarch64')",
"dearpygui>=2.1.0",
]
[project.urls]

@ -0,0 +1,179 @@
import threading
import numpy as np
from abc import ABC, abstractmethod
from typing import Any
from openpilot.tools.lib.logreader import LogReader
from openpilot.tools.lib.log_time_series import msgs_to_time_series
# TODO: support cereal/ZMQ streaming
class DataSource(ABC):
@abstractmethod
def load_data(self) -> dict[str, Any]:
pass
@abstractmethod
def get_duration(self) -> float:
pass
class LogReaderSource(DataSource):
def __init__(self, route_name: str):
self.route_name = route_name
self._duration = 0.0
self._start_time_mono = 0.0
def load_data(self) -> dict[str, Any]:
lr = LogReader(self.route_name)
raw_time_series = msgs_to_time_series(lr)
processed_data = self._expand_list_fields(raw_time_series)
# Calculate timing information
times = [data['t'] for data in processed_data.values() if 't' in data and len(data['t']) > 0]
if times:
all_times = np.concatenate(times)
self._start_time_mono = all_times.min()
self._duration = all_times.max() - self._start_time_mono
return {'time_series_data': processed_data, 'route_start_time_mono': self._start_time_mono, 'duration': self._duration}
def get_duration(self) -> float:
return self._duration
# TODO: lists are expanded, but lists of structs are not
def _expand_list_fields(self, time_series_data):
expanded_data = {}
for msg_type, data in time_series_data.items():
expanded_data[msg_type] = {}
for field, values in data.items():
if field == 't':
expanded_data[msg_type]['t'] = values
continue
if isinstance(values, np.ndarray) and values.dtype == object: # ragged array
lens = np.fromiter((len(v) for v in values), dtype=int, count=len(values))
max_len = lens.max() if lens.size else 0
if max_len > 0:
arr = np.full((len(values), max_len), None, dtype=object)
for i, v in enumerate(values):
arr[i, : lens[i]] = v
for i in range(max_len):
sub_arr = arr[:, i]
expanded_data[msg_type][f"{field}/{i}"] = sub_arr
elif isinstance(values, np.ndarray) and values.ndim > 1: # regular array
for i in range(values.shape[1]):
col_data = values[:, i]
expanded_data[msg_type][f"{field}/{i}"] = col_data
else:
expanded_data[msg_type][field] = values
return expanded_data
class DataLoadedEvent:
def __init__(self, data: dict[str, Any]):
self.data = data
class Observer(ABC):
@abstractmethod
def on_data_loaded(self, event: DataLoadedEvent):
pass
class DataManager:
def __init__(self):
self.time_series_data = {}
self.loading = False
self.route_start_time_mono = 0.0
self.duration = 100.0
self._observers: list[Observer] = []
def add_observer(self, observer: Observer):
self._observers.append(observer)
def remove_observer(self, observer: Observer):
if observer in self._observers:
self._observers.remove(observer)
def _notify_observers(self, event: DataLoadedEvent):
for observer in self._observers:
observer.on_data_loaded(event)
def get_current_value_for_path(self, path: str, time_s: float, last_index: int | None = None):
try:
abs_time_s = self.route_start_time_mono + time_s
msg_type, field_path = path.split('/', 1)
ts_data = self.time_series_data[msg_type]
t, v = ts_data['t'], ts_data[field_path]
if len(t) == 0:
return None, None
if last_index is None: # jump
idx = np.searchsorted(t, abs_time_s, side='right') - 1
else: # continuous playback
idx = last_index
while idx < len(t) - 1 and t[idx + 1] < abs_time_s:
idx += 1
idx = max(0, idx)
return v[idx], idx
except (KeyError, IndexError):
return None, None
def get_all_paths(self) -> list[str]:
all_paths = []
for msg_type, data in self.time_series_data.items():
for key in data.keys():
if key != 't':
all_paths.append(f"{msg_type}/{key}")
return all_paths
def is_path_plottable(self, path: str) -> bool:
try:
msg_type, field_path = path.split('/', 1)
value_array = self.time_series_data.get(msg_type, {}).get(field_path)
if value_array is not None: # only numbers and bools are plottable
return np.issubdtype(value_array.dtype, np.number) or np.issubdtype(value_array.dtype, np.bool_)
except (ValueError, KeyError):
pass
return False
def get_time_series_data(self, path: str) -> tuple | None:
try:
msg_type, field_path = path.split('/', 1)
ts_data = self.time_series_data[msg_type]
time_array = ts_data['t']
plot_values = ts_data[field_path]
if len(time_array) == 0:
return None
rel_time_array = time_array - self.route_start_time_mono
return rel_time_array, plot_values
except (KeyError, ValueError):
return None
def load_route(self, route_name: str):
if self.loading:
return
self.loading = True
data_source = LogReaderSource(route_name)
threading.Thread(target=self._load_in_background, args=(data_source,), daemon=True).start()
def _load_in_background(self, data_source: DataSource):
try:
data = data_source.load_data()
self.time_series_data = data['time_series_data']
self.route_start_time_mono = data['route_start_time_mono']
self.duration = data['duration']
self._notify_observers(DataLoadedEvent(data))
except Exception as e:
print(f"Error loading route: {e}")
finally:
self.loading = False

@ -0,0 +1,304 @@
import uuid
import dearpygui.dearpygui as dpg
from abc import ABC, abstractmethod
from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.views import ViewPanel, TimeSeriesPanel
class LayoutNode(ABC):
def __init__(self, node_id: str = None):
self.node_id = node_id or str(uuid.uuid4())
self.tag = None
@abstractmethod
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1):
pass
@abstractmethod
def destroy_ui(self):
pass
@abstractmethod
def preserve_data(self):
pass
class LeafNode(LayoutNode):
"""Leaf node that contains a single ViewPanel with controls"""
def __init__(self, panel: ViewPanel, layout_manager=None, scale: float = 1.0, node_id: str = None):
super().__init__(node_id)
self.panel = panel
self.layout_manager = layout_manager
self.scale = scale
def preserve_data(self):
self.panel.preserve_data()
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1):
"""Create UI container with controls and panel"""
self.tag = f"leaf_{self.node_id}"
with dpg.child_window(tag=self.tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True):
# Control bar
with dpg.group(horizontal=True):
dpg.add_input_text(tag=f"title_{self.node_id}", default_value=self.panel.title, width=int(100 * self.scale), callback=self._on_title_change)
dpg.add_combo(
items=["Time Series"], # "Camera", "Text Log", "Map View"],
tag=f"type_{self.node_id}",
default_value="Time Series",
width=int(100 * self.scale),
callback=self._on_type_change,
)
dpg.add_button(label="Clear", callback=self._clear, width=int(50 * self.scale))
dpg.add_button(label="Delete", callback=self._delete, width=int(50 * self.scale))
dpg.add_button(label="Split H", callback=lambda: self._split("horizontal"), width=int(50 * self.scale))
dpg.add_button(label="Split V", callback=lambda: self._split("vertical"), width=int(50 * self.scale))
dpg.add_separator()
# Panel content area
panel_area_tag = f"panel_area_{self.node_id}"
with dpg.child_window(tag=panel_area_tag, border=False, height=-1, width=-1, no_scrollbar=True):
self.panel.create_ui(panel_area_tag)
def destroy_ui(self):
if self.panel:
self.panel.destroy_ui()
if self.tag and dpg.does_item_exist(self.tag):
dpg.delete_item(self.tag)
def _on_title_change(self, sender, app_data):
self.panel.title = app_data
def _on_type_change(self, sender, app_data):
print(f"Panel type change requested: {app_data}")
def _split(self, orientation: str):
if self.layout_manager:
self.layout_manager.split_node(self, orientation)
def _clear(self):
if hasattr(self.panel, 'clear_all_series'):
self.panel.clear_all_series()
def _delete(self):
if self.layout_manager:
self.layout_manager.delete_node(self)
class SplitterNode(LayoutNode):
"""Splitter node that contains multiple child nodes"""
def __init__(self, children: list[LayoutNode], orientation: str = "horizontal", node_id: str = None):
super().__init__(node_id)
self.children = children if children else []
self.orientation = orientation
self.child_proportions = [1.0 / len(self.children) for _ in self.children] if self.children else []
self.child_container_tags = [] # Track container tags for resizing (different from child tag)
def preserve_data(self):
for child in self.children:
child.preserve_data()
def add_child(self, child: LayoutNode, index: int = None):
if index is None:
self.children.append(child)
self.child_proportions.append(0.0)
else:
self.children.insert(index, child)
self.child_proportions.insert(index, 0.0)
self._redistribute_proportions()
def remove_child(self, child: LayoutNode):
if child in self.children:
index = self.children.index(child)
self.children.remove(child)
self.child_proportions.pop(index)
child.destroy_ui()
if self.children:
self._redistribute_proportions()
def replace_child(self, old_child: LayoutNode, new_child: LayoutNode):
try:
index = self.children.index(old_child)
self.children[index] = new_child
return index
except ValueError:
return None
def _redistribute_proportions(self):
if self.children:
equal_proportion = 1.0 / len(self.children)
self.child_proportions = [equal_proportion for _ in self.children]
def resize_children(self):
if not self.tag or not dpg.does_item_exist(self.tag):
return
available_width, available_height = dpg.get_item_rect_size(dpg.get_item_parent(self.tag))
for i, container_tag in enumerate(self.child_container_tags):
if not dpg.does_item_exist(container_tag):
continue
proportion = self.child_proportions[i] if i < len(self.child_proportions) else (1.0 / len(self.children))
if self.orientation == "horizontal":
new_width = max(100, int(available_width * proportion))
dpg.configure_item(container_tag, width=new_width)
else:
new_height = max(100, int(available_height * proportion))
dpg.configure_item(container_tag, height=new_height)
child = self.children[i] if i < len(self.children) else None
if child and isinstance(child, SplitterNode):
child.resize_children()
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1):
self.tag = f"splitter_{self.node_id}"
self.child_container_tags = []
if self.orientation == "horizontal":
with dpg.group(tag=self.tag, parent=parent_tag, horizontal=True):
for i, child in enumerate(self.children):
proportion = self.child_proportions[i]
child_width = max(100, int(width * proportion))
container_tag = f"child_container_{self.node_id}_{i}"
self.child_container_tags.append(container_tag)
with dpg.child_window(tag=container_tag, width=child_width, height=-1, border=False, no_scrollbar=True, resizable_x=False):
child.create_ui(container_tag, child_width, height)
else:
with dpg.group(tag=self.tag, parent=parent_tag):
for i, child in enumerate(self.children):
proportion = self.child_proportions[i]
child_height = max(100, int(height * proportion))
container_tag = f"child_container_{self.node_id}_{i}"
self.child_container_tags.append(container_tag)
with dpg.child_window(tag=container_tag, width=-1, height=child_height, border=False, no_scrollbar=True, resizable_y=False):
child.create_ui(container_tag, width, child_height)
def destroy_ui(self):
for child in self.children:
if child:
child.destroy_ui()
if self.tag and dpg.does_item_exist(self.tag):
dpg.delete_item(self.tag)
self.child_container_tags.clear()
class PlotLayoutManager:
def __init__(self, data_manager: DataManager, playback_manager, scale: float = 1.0):
self.data_manager = data_manager
self.playback_manager = playback_manager
self.scale = scale
self.root_node: LayoutNode = None
self.container_tag = "plot_layout_container"
self._initialize_default_layout()
def _initialize_default_layout(self):
panel = TimeSeriesPanel(self.data_manager, self.playback_manager)
self.root_node = LeafNode(panel, layout_manager=self, scale=self.scale)
def create_ui(self, parent_tag: str):
if dpg.does_item_exist(self.container_tag):
dpg.delete_item(self.container_tag)
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True):
if self.root_node:
container_width, container_height = dpg.get_item_rect_size(self.container_tag)
self.root_node.create_ui(self.container_tag, container_width, container_height)
def on_viewport_resize(self):
if isinstance(self.root_node, SplitterNode):
self.root_node.resize_children()
def split_node(self, node: LeafNode, orientation: str):
# create new panel for the split
new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) # TODO: create same type of panel as the split
new_leaf = LeafNode(new_panel, layout_manager=self, scale=self.scale)
parent_node, child_index = self._find_parent_and_index(node)
if parent_node is None: # root node - create new splitter as root
node.preserve_data()
self.root_node = SplitterNode([node, new_leaf], orientation)
self._update_ui_for_node(self.root_node, self.container_tag)
elif isinstance(parent_node, SplitterNode) and parent_node.orientation == orientation: # same orientation - add to existing splitter
parent_node.add_child(new_leaf, child_index + 1)
self._update_ui_for_node(parent_node)
else: # different orientation - replace node with new splitter
node.preserve_data()
new_splitter = SplitterNode([node, new_leaf], orientation)
self._replace_child_in_parent(parent_node, node, new_splitter)
def delete_node(self, node: LeafNode): # TODO: actually delete the node, not just the ui for the node
parent_node, child_index = self._find_parent_and_index(node)
if parent_node is None: # root deletion - replace with new default
node.destroy_ui()
self._initialize_default_layout()
self._update_ui_for_node(self.root_node, self.container_tag)
elif isinstance(parent_node, SplitterNode):
parent_node.remove_child(node)
if len(parent_node.children) == 1: # collapse splitter --> leaf to just leaf
remaining_child = parent_node.children[0]
grandparent_node, parent_index = self._find_parent_and_index(parent_node)
if grandparent_node is None: # promote remaining child to root
remaining_child.preserve_data()
parent_node.children.remove(remaining_child)
self.root_node = remaining_child
parent_node.destroy_ui()
self._update_ui_for_node(self.root_node, self.container_tag)
else: # replace splitter with remaining child in grandparent node
self._replace_child_in_parent(grandparent_node, parent_node, remaining_child)
else: # update splpitter contents
self._update_ui_for_node(parent_node)
def _replace_child_in_parent(self, parent_node: SplitterNode, old_child: LayoutNode, new_child: LayoutNode):
old_child.preserve_data() # save data and for when recreating ui for the node
child_index = parent_node.children.index(old_child)
child_container_tag = f"child_container_{parent_node.node_id}_{child_index}"
parent_node.replace_child(old_child, new_child)
# Clean up old child if it's being replaced (not just moved)
if old_child != new_child:
old_child.destroy_ui()
if dpg.does_item_exist(child_container_tag):
dpg.delete_item(child_container_tag, children_only=True)
container_width, container_height = dpg.get_item_rect_size(child_container_tag)
new_child.create_ui(child_container_tag, container_width, container_height)
def _update_ui_for_node(self, node: LayoutNode, container_tag: str = None):
node.preserve_data()
if container_tag: # update node in a specific container (usually root)
dpg.delete_item(container_tag, children_only=True)
container_width, container_height = dpg.get_item_rect_size(container_tag)
node.create_ui(container_tag, container_width, container_height)
else: # update node in its current location (splitter updates)
if node.tag and dpg.does_item_exist(node.tag):
parent_container = dpg.get_item_parent(node.tag)
node.destroy_ui()
if parent_container and dpg.does_item_exist(parent_container):
parent_width, parent_height = dpg.get_item_rect_size(parent_container)
node.create_ui(parent_container, parent_width, parent_height)
def _find_parent_and_index(self, target_node: LayoutNode) -> tuple: # TODO: probably can be stored in child
def search_recursive(node: LayoutNode, parent: LayoutNode = None, index: int = 0):
if node == target_node:
return parent, index
if isinstance(node, SplitterNode):
for i, child in enumerate(node.children):
result = search_recursive(child, node, i)
if result[0] is not None:
return result
return None, None
return search_recursive(self.root_node)

@ -0,0 +1,240 @@
import argparse
import pyautogui
import subprocess
import dearpygui.dearpygui as dpg
import threading
from openpilot.tools.jotpluggler.data import DataManager, Observer, DataLoadedEvent
from openpilot.tools.jotpluggler.views import DataTreeView
from openpilot.tools.jotpluggler.layout import PlotLayoutManager, SplitterNode, LeafNode
class PlaybackManager:
def __init__(self):
self.is_playing = False
self.current_time_s = 0.0
self.duration_s = 100.0
self.last_indices = {}
def set_route_duration(self, duration: float):
self.duration_s = duration
self.seek(min(self.current_time_s, duration))
def toggle_play_pause(self):
if not self.is_playing and self.current_time_s >= self.duration_s:
self.seek(0.0)
self.is_playing = not self.is_playing
def seek(self, time_s: float):
self.is_playing = False
self.current_time_s = max(0.0, min(time_s, self.duration_s))
self.last_indices.clear()
def update_time(self, delta_t: float) -> float:
if self.is_playing:
self.current_time_s = min(self.current_time_s + delta_t, self.duration_s)
if self.current_time_s >= self.duration_s:
self.is_playing = False
return self.current_time_s
def update_index(self, path: str, new_idx: int | None):
if new_idx is not None:
self.last_indices[path] = new_idx
def calculate_avg_char_width(font):
sample_text = "abcdefghijklmnopqrstuvwxyz0123456789"
if size := dpg.get_text_size(sample_text, font=font):
return size[0] / len(sample_text)
return None
def format_and_truncate(value, available_width: float, avg_char_width: float) -> str:
s = str(value)
max_chars = int(available_width / avg_char_width) - 3
if len(s) > max_chars:
return s[: max(0, max_chars)] + "..."
return s
class MainController(Observer):
def __init__(self, scale: float = 1.0):
self.ui_lock = threading.Lock()
self.scale = scale
self.data_manager = DataManager()
self.playback_manager = PlaybackManager()
self._create_global_themes()
self.data_tree_view = DataTreeView(self.data_manager, self.ui_lock)
self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, scale=self.scale)
self.data_manager.add_observer(self)
self.avg_char_width = None
def _create_global_themes(self):
with dpg.theme(tag="global_line_theme"):
with dpg.theme_component(dpg.mvLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
with dpg.theme(tag="global_timeline_theme"):
with dpg.theme_component(dpg.mvInfLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)
def on_data_loaded(self, event: DataLoadedEvent):
self.playback_manager.set_route_duration(event.data['duration'])
num_msg_types = len(event.data['time_series_data'])
dpg.set_value("load_status", f"Loaded {num_msg_types} message types")
dpg.configure_item("load_button", enabled=True)
dpg.configure_item("timeline_slider", max_value=event.data['duration'])
def setup_ui(self):
with dpg.item_handler_registry(tag="tree_node_handler"):
dpg.add_item_toggled_open_handler(callback=self.data_tree_view.update_active_nodes_list)
dpg.set_viewport_resize_callback(callback=self.on_viewport_resize)
with dpg.window(tag="Primary Window"):
with dpg.group(horizontal=True):
# Left panel - Data tree
with dpg.child_window(label="Data Pool", width=300 * self.scale, tag="data_pool_window", border=True, resizable_x=True):
with dpg.group(horizontal=True):
dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...")
dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
dpg.add_text("Ready to load route", tag="load_status")
dpg.add_separator()
dpg.add_text("Available Data")
dpg.add_separator()
dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data)
dpg.add_separator()
with dpg.group(tag="data_tree_container", track_offset=True):
pass
# Right panel - Plots and timeline
with dpg.group():
with dpg.child_window(label="Plot Window", border=True, height=-(30 + 13 * self.scale), tag="main_plot_area"):
self.plot_layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Timeline", border=True):
with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False):
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # Play button
dpg.add_table_column(width_stretch=True) # Timeline slider
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter
with dpg.table_row():
dpg.add_button(label="Play", tag="play_pause_button", callback=self.toggle_play_pause, width=int(50 * self.scale))
dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag)
dpg.add_text("", tag="fps_counter")
dpg.set_primary_window("Primary Window", True)
def on_viewport_resize(self):
self.plot_layout_manager.on_viewport_resize()
def load_route(self):
route_name = dpg.get_value("route_input").strip()
if route_name:
dpg.set_value("load_status", "Loading route...")
dpg.configure_item("load_button", enabled=False)
self.data_manager.load_route(route_name)
def search_data(self):
search_term = dpg.get_value("search_input")
self.data_tree_view.search_data(search_term)
def toggle_play_pause(self, sender):
self.playback_manager.toggle_play_pause()
label = "Pause" if self.playback_manager.is_playing else "Play"
dpg.configure_item(sender, label=label)
def timeline_drag(self, sender, app_data):
self.playback_manager.seek(app_data)
dpg.configure_item("play_pause_button", label="Play")
def update_frame(self, font):
with self.ui_lock:
if self.avg_char_width is None:
self.avg_char_width = calculate_avg_char_width(font) # must be calculated after first frame
new_time = self.playback_manager.update_time(dpg.get_delta_time())
if not dpg.is_item_active("timeline_slider"):
dpg.set_value("timeline_slider", new_time)
self._update_timeline_indicators(new_time)
if not self.data_manager.loading and self.avg_char_width:
self._update_data_values()
dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS")
def _update_data_values(self):
pool_width = dpg.get_item_rect_size("data_pool_window")[0]
value_column_width = pool_width * 0.5
active_nodes = self.data_tree_view.active_leaf_nodes
for node in active_nodes:
path = node.full_path
value_tag = f"value_{path}"
if dpg.does_item_exist(value_tag) and dpg.is_item_visible(value_tag):
last_index = self.playback_manager.last_indices.get(path)
value, new_idx = self.data_manager.get_current_value_for_path(path, self.playback_manager.current_time_s, last_index)
if value is not None:
self.playback_manager.update_index(path, new_idx)
formatted_value = format_and_truncate(value, value_column_width, self.avg_char_width)
dpg.set_value(value_tag, formatted_value)
def _update_timeline_indicators(self, current_time_s: float):
def update_node_recursive(node):
if isinstance(node, LeafNode):
if hasattr(node.panel, 'update_timeline_indicator'):
node.panel.update_timeline_indicator(current_time_s)
elif isinstance(node, SplitterNode):
for child in node.children:
update_node_recursive(child)
if self.plot_layout_manager.root_node:
update_node_recursive(self.plot_layout_manager.root_node)
def main(route_to_load=None):
dpg.create_context()
# TODO: find better way of calculating display scaling
try:
w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution
scale = pyautogui.size()[0] / w # scaled resolution
except Exception:
scale = 1
with dpg.font_registry():
default_font = dpg.add_font("selfdrive/assets/fonts/Inter-Regular.ttf", int(13 * scale))
dpg.bind_font(default_font)
viewport_width, viewport_height = int(1200 * scale), int(800 * scale)
mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays)
dpg.create_viewport(
title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2
)
dpg.setup_dearpygui()
controller = MainController(scale=scale)
controller.setup_ui()
if route_to_load:
dpg.set_value("route_input", route_to_load)
controller.load_route()
dpg.show_viewport()
# Main loop
while dpg.is_dearpygui_running():
controller.update_frame(default_font)
dpg.render_dearpygui_frame()
dpg.destroy_context()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.")
parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.")
args = parser.parse_args()
main(route_to_load=args.route)

@ -0,0 +1,308 @@
import os
import re
import uuid
import threading
import dearpygui.dearpygui as dpg
from abc import ABC, abstractmethod
from openpilot.tools.jotpluggler.data import Observer, DataLoadedEvent, DataManager
class ViewPanel(ABC):
"""Abstract base class for all view panels that can be displayed in a plot container"""
def __init__(self, panel_id: str = None):
self.panel_id = panel_id or str(uuid.uuid4())
self.title = "Untitled Panel"
@abstractmethod
def create_ui(self, parent_tag: str):
pass
@abstractmethod
def destroy_ui(self):
pass
@abstractmethod
def get_panel_type(self) -> str:
pass
@abstractmethod
def preserve_data(self):
pass
class TimeSeriesPanel(ViewPanel, Observer):
def __init__(self, data_manager: DataManager, playback_manager, panel_id: str = None):
super().__init__(panel_id)
self.data_manager = data_manager
self.playback_manager = playback_manager
self.title = "Time Series Plot"
self.plotted_series: set[str] = set()
self.plot_tag = None
self.x_axis_tag = None
self.y_axis_tag = None
self.timeline_indicator_tag = None
self._ui_created = False
# Store series data for restoration and legend management
self._preserved_series_data = [] # TODO: the way we do this right now doesn't make much sense
self._series_legend_tags = {} # Maps series_path to legend tag
self.data_manager.add_observer(self)
def preserve_data(self):
self._preserved_series_data = []
if self.plotted_series and self._ui_created:
for series_path in self.plotted_series:
time_value_data = self.data_manager.get_time_series_data(series_path)
if time_value_data:
self._preserved_series_data.append((series_path, time_value_data))
def create_ui(self, parent_tag: str):
self.plot_tag = f"plot_{self.panel_id}"
self.x_axis_tag = f"{self.plot_tag}_x_axis"
self.y_axis_tag = f"{self.plot_tag}_y_axis"
self.timeline_indicator_tag = f"{self.plot_tag}_timeline"
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, label="", tag=self.x_axis_tag)
dpg.add_plot_axis(dpg.mvYAxis, label="", tag=self.y_axis_tag)
timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag)
dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme")
# Restore series from preserved data
if self._preserved_series_data:
self.plotted_series.clear()
for series_path, (rel_time_array, value_array) in self._preserved_series_data:
self._add_series_with_data(series_path, rel_time_array, value_array)
self._preserved_series_data = []
self._ui_created = True
def update_timeline_indicator(self, current_time_s: float):
if not self._ui_created or not dpg.does_item_exist(self.timeline_indicator_tag):
return
dpg.set_value(self.timeline_indicator_tag, [[current_time_s], [0]]) # vertical line position
if self.plotted_series: # update legend labels with current values
for series_path in self.plotted_series:
last_index = self.playback_manager.last_indices.get(series_path)
value, new_idx = self.data_manager.get_current_value_for_path(series_path, current_time_s, last_index)
if value is not None:
self.playback_manager.update_index(series_path, new_idx)
if isinstance(value, (int, float)):
if isinstance(value, float):
formatted_value = f"{value:.4f}" if abs(value) < 1000 else f"{value:.3e}"
else:
formatted_value = str(value)
else:
formatted_value = str(value)
# Update the series label to include current value
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}"
legend_label = f"{series_path}: {formatted_value}"
if dpg.does_item_exist(series_tag):
dpg.configure_item(series_tag, label=legend_label)
def _add_series_with_data(self, series_path: str, rel_time_array, value_array):
if series_path in self.plotted_series:
return False
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}"
line_series_tag = dpg.add_line_series(x=rel_time_array.tolist(), y=value_array.tolist(), label=series_path, parent=self.y_axis_tag, tag=series_tag)
dpg.bind_item_theme(line_series_tag, "global_line_theme")
self.plotted_series.add(series_path)
dpg.fit_axis_data(self.x_axis_tag)
dpg.fit_axis_data(self.y_axis_tag)
return True
def destroy_ui(self):
if self.plot_tag and dpg.does_item_exist(self.plot_tag):
dpg.delete_item(self.plot_tag)
# self.data_manager.remove_observer(self)
self._series_legend_tags.clear()
self._ui_created = False
def get_panel_type(self) -> str:
return "timeseries"
def add_series(self, series_path: str) -> bool:
if series_path in self.plotted_series:
return False
time_value_data = self.data_manager.get_time_series_data(series_path)
if time_value_data is None:
return False
rel_time_array, value_array = time_value_data
return self._add_series_with_data(series_path, rel_time_array, value_array)
def clear_all_series(self):
for series_path in self.plotted_series.copy():
self.remove_series(series_path)
def remove_series(self, series_path: str):
if series_path in self.plotted_series:
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}"
if dpg.does_item_exist(series_tag):
dpg.delete_item(series_tag)
self.plotted_series.remove(series_path)
if series_path in self._series_legend_tags: # Clean up legend tag mapping
del self._series_legend_tags[series_path]
def on_data_loaded(self, event: DataLoadedEvent):
for series_path in self.plotted_series.copy():
self._update_series_data(series_path)
def _update_series_data(self, series_path: str):
time_value_data = self.data_manager.get_time_series_data(series_path)
if time_value_data is None:
return False
rel_time_array, value_array = time_value_data
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}"
if dpg.does_item_exist(series_tag):
dpg.set_value(series_tag, [rel_time_array.tolist(), value_array.tolist()])
dpg.fit_axis_data(self.x_axis_tag)
dpg.fit_axis_data(self.y_axis_tag)
return True
else:
self.plotted_series.discard(series_path)
return False
def _on_series_drop(self, sender, app_data, user_data):
series_path = app_data
self.add_series(series_path)
class DataTreeNode:
def __init__(self, name: str, full_path: str = ""):
self.name = name
self.full_path = full_path
self.children = {}
self.is_leaf = False
class DataTreeView(Observer):
def __init__(self, data_manager: DataManager, ui_lock: threading.Lock):
self.data_manager = data_manager
self.ui_lock = ui_lock
self.current_search = ""
self.data_tree = DataTreeNode(name="root")
self.active_leaf_nodes = []
self.data_manager.add_observer(self)
def on_data_loaded(self, event: DataLoadedEvent):
with self.ui_lock:
self.populate_data_tree()
def populate_data_tree(self):
if not dpg.does_item_exist("data_tree_container"):
return
dpg.delete_item("data_tree_container", children_only=True)
search_term = self.current_search.strip().lower()
self.data_tree = DataTreeNode(name="root")
all_paths = self.data_manager.get_all_paths()
for path in sorted(all_paths):
if not self._should_display_path(path, search_term):
continue
parts = path.split('/')
current_node = self.data_tree
current_path_prefix = ""
for part in parts:
current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part
if part not in current_node.children:
current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix)
current_node = current_node.children[part]
current_node.is_leaf = True
self._create_ui_from_tree_recursive(self.data_tree, "data_tree_container", search_term)
self.update_active_nodes_list()
def _should_display_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 _create_ui_from_tree_recursive(self, node: DataTreeNode, parent_tag: str, search_term: str):
sorted_children = sorted(node.children.values(), key=self._natural_sort_key)
for child in sorted_children:
if child.is_leaf:
is_plottable = self.data_manager.is_path_plottable(child.full_path)
# Create draggable item
with dpg.group(parent=parent_tag) as draggable_group:
with dpg.table(header_row=False, borders_innerV=False, borders_outerH=False, borders_outerV=False, policy=dpg.mvTable_SizingStretchProp):
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(child.name)
dpg.add_text("N/A", tag=f"value_{child.full_path}")
# Add drag payload if plottable
if is_plottable:
with dpg.drag_payload(parent=draggable_group, drag_data=child.full_path, payload_type="TIMESERIES_PAYLOAD"):
dpg.add_text(f"Plot: {child.full_path}")
else:
node_tag = f"tree_{child.full_path}"
label = child.name
if '/' not in child.full_path:
sample_count = len(self.data_manager.time_series_data.get(child.full_path, {}).get('t', []))
label = f"{child.name} ({sample_count} samples)"
should_open = bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_all_descendant_paths(child))
with dpg.tree_node(label=label, parent=parent_tag, tag=node_tag, default_open=should_open):
dpg.bind_item_handler_registry(node_tag, "tree_node_handler")
self._create_ui_from_tree_recursive(child, node_tag, search_term)
def _get_all_descendant_paths(self, node: DataTreeNode):
for child_name, child_node in node.children.items():
child_name_lower = child_name.lower()
if child_node.is_leaf:
yield child_name_lower
else:
for path in self._get_all_descendant_paths(child_node):
yield f"{child_name_lower}/{path}"
def search_data(self, search_term: str):
self.current_search = search_term
self.populate_data_tree()
def update_active_nodes_list(self, sender=None, app_data=None, user_data=None):
self.active_leaf_nodes = self.get_active_leaf_nodes(self.data_tree)
def get_active_leaf_nodes(self, node: DataTreeNode):
active_leaves = []
for child in node.children.values():
if child.is_leaf:
active_leaves.append(child)
else:
node_tag = f"tree_{child.full_path}"
if dpg.does_item_exist(node_tag) and dpg.get_value(node_tag):
active_leaves.extend(self.get_active_leaf_nodes(child))
return active_leaves

@ -451,6 +451,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fc/c0a3f4c4eaa5a22fbef91713474666e13d0ea2a69c84532579490a9f2cc8/dbus_next-0.2.3-py3-none-any.whl", hash = "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b", size = 57885, upload-time = "2021-07-25T22:11:25.466Z" },
]
[[package]]
name = "dearpygui"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/fe/66293fc40254a29f060efd3398f2b1001ed79263ae1837db9ec42caa8f1d/dearpygui-2.1.0-cp311-cp311-macosx_10_6_x86_64.whl", hash = "sha256:03e5dc0b3dd2f7965e50bbe41f3316a814408064b582586de994d93afedb125c", size = 2100924, upload-time = "2025-07-07T14:20:00.602Z" },
{ url = "https://files.pythonhosted.org/packages/c4/4d/9fa1c3156ba7bbf4dc89e2e322998752fccfdc3575923a98dd6a4da48911/dearpygui-2.1.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b5b37710c3fa135c48e2347f39ecd1f415146e86db5d404707a0bf72d16bd304", size = 1874441, upload-time = "2025-07-07T14:20:09.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/3c/af5673b50699e1734296a0b5bcef39bb6989175b001ad1f9b0e7888ad90d/dearpygui-2.1.0-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b0cfd7ac7eaa090fc22d6aa60fc4b527fc631cee10c348e4d8df92bb39af03d2", size = 2636574, upload-time = "2025-07-07T14:20:14.951Z" },
{ url = "https://files.pythonhosted.org/packages/7f/db/ed4db0bb3d88e7a8c405472641419086bef9632c4b8b0489dc0c43519c0d/dearpygui-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a9af54f96d3ef30c5db9d12cdf3266f005507396fb0da2e12e6b22b662161070", size = 1810266, upload-time = "2025-07-07T14:19:51.565Z" },
{ url = "https://files.pythonhosted.org/packages/55/9d/20a55786cc9d9266395544463d5db3be3528f7d5244bc52ba760de5dcc2d/dearpygui-2.1.0-cp312-cp312-macosx_10_6_x86_64.whl", hash = "sha256:1270ceb9cdb8ecc047c42477ccaa075b7864b314a5d09191f9280a24c8aa90a0", size = 2101499, upload-time = "2025-07-07T14:20:01.701Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/39d820796b7ac4d0ebf93306c1f031bf3516b159408286f1fb495c6babeb/dearpygui-2.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:ce9969eb62057b9d4c88a8baaed13b5fbe4058caa9faf5b19fec89da75aece3d", size = 1874385, upload-time = "2025-07-07T14:20:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/fc/26/c29998ffeb5eb8d638f307851e51a81c8bd4aeaf89ad660fc67ea4d1ac1a/dearpygui-2.1.0-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:a3ca8cf788db63ef7e2e8d6f277631b607d548b37606f080ca1b42b1f0a9b183", size = 2635863, upload-time = "2025-07-07T14:20:17.186Z" },
{ url = "https://files.pythonhosted.org/packages/28/9c/3ab33927f1d8c839c5b7033a33d44fc9f0aeb00c264fc9772cb7555a03c4/dearpygui-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:43f0e4db9402f44fc3683a1f5c703564819de18cc15a042de7f1ed1c8cb5d148", size = 1810460, upload-time = "2025-07-07T14:19:53.13Z" },
]
[[package]]
name = "dictdiffer"
version = "0.9.0"
@ -622,10 +637,10 @@ name = "gymnasium"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle" },
{ name = "farama-notifications" },
{ name = "numpy" },
{ name = "typing-extensions" },
{ name = "cloudpickle", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "farama-notifications", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/17/c2a0e15c2cd5a8e788389b280996db927b923410de676ec5c7b2695e9261/gymnasium-1.2.0.tar.gz", hash = "sha256:344e87561012558f603880baf264ebc97f8a5c997a957b0c9f910281145534b0", size = 821142, upload-time = "2025-06-27T08:21:20.262Z" }
wheels = [
@ -903,22 +918,22 @@ name = "metadrive-simulator"
version = "0.4.2.4"
source = { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" }
dependencies = [
{ name = "filelock" },
{ name = "gymnasium" },
{ name = "lxml" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "opencv-python-headless" },
{ name = "panda3d" },
{ name = "panda3d-gltf" },
{ name = "pillow" },
{ name = "progressbar" },
{ name = "psutil" },
{ name = "pygments" },
{ name = "requests" },
{ name = "shapely" },
{ name = "tqdm" },
{ name = "yapf" },
{ name = "filelock", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "gymnasium", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "lxml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "opencv-python-headless", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "panda3d-gltf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "progressbar", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "psutil", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pygments", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "requests", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "shapely", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "yapf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
wheels = [
{ url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl", hash = "sha256:fbf0ea9be67e65cd45d38ff930e3d49f705dd76c9ddbd1e1482e3f87b61efcef" },
@ -1237,6 +1252,7 @@ dependencies = [
{ name = "cffi" },
{ name = "crcmod" },
{ name = "cython" },
{ name = "dearpygui" },
{ name = "future-fstrings" },
{ name = "inputs" },
{ name = "json-rpc" },
@ -1327,6 +1343,7 @@ requires-dist = [
{ name = "crcmod" },
{ name = "cython" },
{ name = "dbus-next", marker = "extra == 'dev'" },
{ name = "dearpygui", specifier = ">=2.1.0" },
{ name = "dictdiffer", marker = "extra == 'dev'" },
{ name = "future-fstrings" },
{ name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" },
@ -1422,8 +1439,8 @@ name = "panda3d-gltf"
version = "0.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "panda3d" },
{ name = "panda3d-simplepbr" },
{ name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "panda3d-simplepbr", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/7f/9f18fc3fa843a080acb891af6bcc12262e7bdf1d194a530f7042bebfc81f/panda3d-gltf-0.13.tar.gz", hash = "sha256:d06d373bdd91cf530909b669f43080e599463bbf6d3ef00c3558bad6c6b19675", size = 25573, upload-time = "2021-05-21T05:46:32.738Z" }
wheels = [
@ -1435,8 +1452,8 @@ name = "panda3d-simplepbr"
version = "0.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "panda3d" },
{ name = "typing-extensions" },
{ name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/be/c4d1ded04c22b357277cf6e6a44c1ab4abb285a700bd1991460460e05b99/panda3d_simplepbr-0.13.1.tar.gz", hash = "sha256:c83766d7c8f47499f365a07fe1dff078fc8b3054c2689bdc8dceabddfe7f1a35", size = 6216055, upload-time = "2025-03-30T16:57:41.087Z" }
wheels = [
@ -4173,9 +4190,9 @@ name = "pyopencl"
version = "2025.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "platformdirs" },
{ name = "pytools" },
{ name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pytools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/88/0ac460d3e2def08b2ad6345db6a13613815f616bbbd60c6f4bdf774f4c41/pyopencl-2025.1.tar.gz", hash = "sha256:0116736d7f7920f87b8db4b66a03f27b1d930d2e37ddd14518407cc22dd24779", size = 422510, upload-time = "2025-01-22T00:16:58.421Z" }
wheels = [
@ -4351,7 +4368,7 @@ wheels = [
[[package]]
name = "pytest-xdist"
version = "3.7.1.dev24+g2b4372bd6"
version = "3.7.1.dev24+g2b4372b"
source = { git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da#2b4372bd62699fb412c4fe2f95bf9f01bd2018da" }
dependencies = [
{ name = "execnet" },
@ -4393,9 +4410,9 @@ name = "pytools"
version = "2024.1.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "siphash24" },
{ name = "typing-extensions" },
{ name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "siphash24", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/56e109c0307f831b5d598ad73976aaaa84b4d0e98da29a642e797eaa940c/pytools-2024.1.10.tar.gz", hash = "sha256:9af6f4b045212c49be32bb31fe19606c478ee4b09631886d05a32459f4ce0a12", size = 81741, upload-time = "2024-07-17T18:47:38.287Z" }
wheels = [
@ -4719,7 +4736,7 @@ name = "shapely"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/3c/2da625233f4e605155926566c0e7ea8dda361877f48e8b1655e53456f252/shapely-2.1.1.tar.gz", hash = "sha256:500621967f2ffe9642454808009044c21e5b35db89ce69f8a2042c2ffd0e2772", size = 315422, upload-time = "2025-05-19T11:04:41.265Z" }
wheels = [
@ -4948,7 +4965,7 @@ name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [

Loading…
Cancel
Save