Source code for isaaclab.ui.widgets.line_plot

# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import colorsys
from contextlib import suppress
from typing import TYPE_CHECKING

import numpy as np

import omni

from isaaclab.sim import SimulationContext

with suppress(ImportError):
    # isaacsim.gui is not available when running in headless mode.
    import isaacsim.gui.components.ui_utils

from .ui_widget_wrapper import UIWidgetWrapper

if TYPE_CHECKING:
    import isaacsim.gui.components
    import omni.ui


[docs] class LiveLinePlot(UIWidgetWrapper): """A 2D line plot widget to display live data. This widget is used to display live data in a 2D line plot. It can be used to display multiple series in the same plot. The widget uses the following layout: .. code-block:: text +-------------------------------------------------------+ | containing_frame | |+-----------------------------------------------------+| | main_plot_frame | ||+---------------------------------------------------+|| ||| plot_frames + grid lines (Z_stacked) ||| ||| (Live Plot Data) ||| |||+-------------------------------------------------+||| ||| legends_frame ||| ||| [x][Series 1] [x][Series 2] [ ][Series 3] ||| |||+-------------------------------------------------+||| ||| limits_frame ||| ||| [Y-Limits] [min] [max] [Autoscale] ||| |||+-------------------------------------------------+||| ||| filter_frame ||| |+-----------------------------------------------------+| +-------------------------------------------------------+ """
[docs] def __init__( self, y_data: list[list[float]], y_min: float = -10, y_max: float = 10, plot_height: int = 150, show_legend: bool = True, legends: list[str] | None = None, max_datapoints: int = 200, ): """Create a new LiveLinePlot widget. Args: y_data: A list of lists of floats containing the data to plot. Each list of floats represents a series in the plot. y_min: The minimum y value to display. Defaults to -10. y_max: The maximum y value to display. Defaults to 10. plot_height: The height of the plot in pixels. Defaults to 150. show_legend: Whether to display the legend. Defaults to True. legends: A list of strings containing the legend labels for each series. If None, the default labels are "Series_0", "Series_1", etc. Defaults to None. max_datapoints: The maximum number of data points to display. If the number of data points exceeds this value, the oldest data points are removed. Defaults to 200. """ super().__init__(self._create_ui_widget()) self.plot_height = plot_height self.show_legend = show_legend self._legends = legends if legends is not None else ["Series_" + str(i) for i in range(len(y_data))] self._y_data = y_data self._colors = self._get_distinct_hex_colors(len(y_data)) self._y_min = y_min if y_min is not None else -10 self._y_max = y_max if y_max is not None else 10 self._max_data_points = max_datapoints self._show_legend = show_legend self._series_visible = [True for _ in range(len(y_data))] self._plot_frames = [] self._plots = [] self._plot_selected_values = [] self._is_built = False self._filter_frame = None self._filter_mode = None self._last_values = None self._is_paused = False # Gets populated when widget is built self._main_plot_frame = None self._autoscale_model = omni.ui.SimpleBoolModel(True)
"""Properties""" @property def autoscale_mode(self) -> bool: return self._autoscale_model.as_bool @property def y_data(self) -> list[list[float]]: """The current data in the plot.""" return self._y_data @property def y_min(self) -> float: """The current minimum y value.""" return self._y_min @property def y_max(self) -> float: """The current maximum y value.""" return self._y_max @property def legends(self) -> list[str]: """The current legend labels.""" return self._legends """ General Functions """ def clear(self): """Clears the plot.""" self._y_data = [[] for _ in range(len(self._y_data))] self._last_values = None for plt in self._plots: plt.set_data() # self._container_frame.rebuild() def add_datapoint(self, y_coords: list[float]): """Add a data point to the plot. The data point is added to the end of the plot. If the number of data points exceeds the maximum number of data points, the oldest data point is removed. ``y_coords`` is assumed to be a list of floats with the same length as the number of series in the plot. Args: y_coords: A list of floats containing the y coordinates of the new data points. """ for idx, y_coord in enumerate(y_coords): if len(self._y_data[idx]) > self._max_data_points: self._y_data[idx] = self._y_data[idx][1:] if self._filter_mode == "Lowpass": if self._last_values is not None: alpha = 0.8 y_coord = self._y_data[idx][-1] * alpha + y_coord * (1 - alpha) elif self._filter_mode == "Integrate": if self._last_values is not None: y_coord = self._y_data[idx][-1] + y_coord elif self._filter_mode == "Derivative": if self._last_values is not None: y_coord = (y_coord - self._last_values[idx]) / SimulationContext.instance().get_rendering_dt() self._y_data[idx].append(float(y_coord)) if self._main_plot_frame is None: # Widget not built, not visible return # Check if the widget has been built, i.e. the plot references have been created. if not self._is_built or self._is_paused: return if len(self._y_data) != len(self._plots): # Plots gotten out of sync, rebuild the widget self._main_plot_frame.rebuild() return if self.autoscale_mode: self._rescale_btn_pressed() for idx, plt in enumerate(self._plots): plt.set_data(*self._y_data[idx]) self._last_values = y_coords # Autoscale the y-axis to the current data """ Internal functions for building the UI. """ def _build_stacked_plots(self, grid: bool = True): """Builds multiple plots stacked on top of each other to display multiple series. This is an internal function to build the plots. It should not be called from outside the class and only from within the build function of a frame. The built widget has the following layout: +-------------------------------------------------------+ | main_plot_frame | ||+---------------------------------------------------+|| ||| ||| ||| y_max|*******-------------------*******| ||| ||| |-------*****-----------**--------| ||| ||| 0|------------**-----***-----------| ||| ||| |--------------***----------------| ||| ||| y_min|---------------------------------| ||| ||| ||| |||+-------------------------------------------------+||| Args: grid: Whether to display grid lines. Defaults to True. """ # Reset lists which are populated in the build function self._plot_frames = [] # Define internal builder function def _build_single_plot(y_data: list[float], color: int, plot_idx: int): """Build a single plot. This is an internal function to build a single plot with the given data and color. This function should only be called from within the build function of a frame. Args: y_data: The data to plot. color: The color of the plot. """ plot = omni.ui.Plot( omni.ui.Type.LINE, self._y_min, self._y_max, *y_data, height=self.plot_height, style={"color": color, "background_color": 0x0}, ) if len(self._plots) <= plot_idx: self._plots.append(plot) self._plot_selected_values.append(omni.ui.SimpleStringModel("")) else: self._plots[plot_idx] = plot # Begin building the widget with omni.ui.HStack(): # Space to the left to add y-axis labels omni.ui.Spacer(width=20) # Built plots for each time series stacked on top of each other with omni.ui.ZStack(): # Background rectangle omni.ui.Rectangle( height=self.plot_height, style={ "background_color": 0x0, "border_color": omni.ui.color.white, "border_width": 0.4, "margin": 0.0, }, ) # Draw grid lines and labels if grid: # Calculate the number of grid lines to display # Absolute range of the plot plot_range = self._y_max - self._y_min grid_resolution = 10 ** np.floor(np.log10(0.5 * plot_range)) plot_range /= grid_resolution # Fraction of the plot range occupied by the first and last grid line first_space = (self._y_max / grid_resolution) - np.floor(self._y_max / grid_resolution) last_space = np.ceil(self._y_min / grid_resolution) - self._y_min / grid_resolution # Number of grid lines to display n_lines = int(plot_range - first_space - last_space) plot_resolution = self.plot_height / plot_range with omni.ui.VStack(): omni.ui.Spacer(height=plot_resolution * first_space) # Draw grid lines with omni.ui.VGrid(row_height=plot_resolution): for grid_line_idx in range(n_lines): # Create grid line with omni.ui.ZStack(): omni.ui.Line( style={ "color": 0xAA8A8777, "background_color": 0x0, "border_width": 0.4, }, alignment=omni.ui.Alignment.CENTER_TOP, height=0, ) with omni.ui.Placer(offset_x=-20): label_value = ( self._y_max - first_space * grid_resolution - grid_line_idx * grid_resolution ) omni.ui.Label( f"{label_value:.3f}", width=8, height=8, alignment=omni.ui.Alignment.RIGHT_TOP, style={ "color": 0xFFFFFFFF, "font_size": 8, }, ) # Create plots for each series for idx, (data, color) in enumerate(zip(self._y_data, self._colors)): plot_frame = omni.ui.Frame( build_fn=lambda y_data=data, plot_idx=idx, color=color: _build_single_plot( y_data, color, plot_idx ), ) plot_frame.visible = self._series_visible[idx] self._plot_frames.append(plot_frame) # Create an invisible frame on top that will give a helpful tooltip self._tooltip_frame = omni.ui.Plot( height=self.plot_height, style={"color": 0xFFFFFFFF, "background_color": 0x0}, ) self._tooltip_frame.set_mouse_pressed_fn(self._mouse_moved_on_plot) # Create top label for the y-axis with omni.ui.Placer(offset_x=-20, offset_y=-8): omni.ui.Label( f"{self._y_max:.3f}", width=8, height=2, alignment=omni.ui.Alignment.LEFT_TOP, style={"color": 0xFFFFFFFF, "font_size": 8}, ) # Create bottom label for the y-axis with omni.ui.Placer(offset_x=-20, offset_y=self.plot_height): omni.ui.Label( f"{self._y_min:.3f}", width=8, height=2, alignment=omni.ui.Alignment.LEFT_BOTTOM, style={"color": 0xFFFFFFFF, "font_size": 8}, ) def _mouse_moved_on_plot(self, x, y, *args): # Show a tooltip with x,y and function values if len(self._y_data) == 0 or len(self._y_data[0]) == 0: # There is no data in the plots, so do nothing return for idx, plot in enumerate(self._plots): x_pos = plot.screen_position_x width = plot.computed_width location_x = (x - x_pos) / width data = self._y_data[idx] n_samples = len(data) selected_sample = int(location_x * n_samples) value = data[selected_sample] # save the value in scientific notation self._plot_selected_values[idx].set_value(f"{value:.3f}") def _build_legends_frame(self): """Build the frame containing the legend for the plots. This is an internal function to build the frame containing the legend for the plots. This function should only be called from within the build function of a frame. The built widget has the following layout: +-------------------------------------------------------+ | legends_frame | ||+---------------------------------------------------+|| ||| ||| ||| [x][Series 1] [x][Series 2] [ ][Series 3] ||| ||| ||| |||+-------------------------------------------------+||| |+-----------------------------------------------------+| +-------------------------------------------------------+ """ if not self._show_legend: return with omni.ui.HStack(): omni.ui.Spacer(width=32) # Find the longest legend to determine the width of the frame max_legend = max([len(legend) for legend in self._legends]) CHAR_WIDTH = 8 with omni.ui.VGrid( row_height=isaacsim.gui.components.ui_utils.LABEL_HEIGHT, column_width=max_legend * CHAR_WIDTH + 6, ): for idx in range(len(self._y_data)): with omni.ui.HStack(): model = omni.ui.SimpleBoolModel() model.set_value(self._series_visible[idx]) omni.ui.CheckBox(model=model, tooltip="", width=4) model.add_value_changed_fn(lambda val, idx=idx: self._change_plot_visibility(idx, val.as_bool)) omni.ui.Spacer(width=2) with omni.ui.VStack(): omni.ui.Label( self._legends[idx], width=max_legend * CHAR_WIDTH, alignment=omni.ui.Alignment.LEFT, style={"color": self._colors[idx], "font_size": 12}, ) omni.ui.StringField( model=self._plot_selected_values[idx], width=max_legend * CHAR_WIDTH, alignment=omni.ui.Alignment.LEFT, style={"color": self._colors[idx], "font_size": 10}, read_only=True, ) def _build_limits_frame(self): """Build the frame containing the controls for the y-axis limits. This is an internal function to build the frame containing the controls for the y-axis limits. This function should only be called from within the build function of a frame. The built widget has the following layout: +-------------------------------------------------------+ | limits_frame | ||+---------------------------------------------------+|| ||| ||| ||| Limits [min] [max] [Re-Sacle] ||| ||| Autoscale[x] ||| ||| ------------------------------------------- ||| |||+-------------------------------------------------+||| """ with omni.ui.VStack(): with omni.ui.HStack(): omni.ui.Label( "Limits", width=isaacsim.gui.components.ui_utils.LABEL_WIDTH, alignment=omni.ui.Alignment.LEFT_CENTER, ) self.lower_limit_drag = omni.ui.FloatDrag(name="min", enabled=True, alignment=omni.ui.Alignment.CENTER) y_min_model = self.lower_limit_drag.model y_min_model.set_value(self._y_min) y_min_model.add_value_changed_fn(lambda x: self._set_y_min(x.as_float)) omni.ui.Spacer(width=2) self.upper_limit_drag = omni.ui.FloatDrag(name="max", enabled=True, alignment=omni.ui.Alignment.CENTER) y_max_model = self.upper_limit_drag.model y_max_model.set_value(self._y_max) y_max_model.add_value_changed_fn(lambda x: self._set_y_max(x.as_float)) omni.ui.Spacer(width=2) omni.ui.Button( "Re-Scale", width=isaacsim.gui.components.ui_utils.BUTTON_WIDTH, clicked_fn=self._rescale_btn_pressed, alignment=omni.ui.Alignment.LEFT_CENTER, style=isaacsim.gui.components.ui_utils.get_style(), ) omni.ui.CheckBox(model=self._autoscale_model, tooltip="", width=4) omni.ui.Line( style={"color": 0x338A8777}, width=omni.ui.Fraction(1), alignment=omni.ui.Alignment.CENTER, ) def _build_filter_frame(self): """Build the frame containing the filter controls. This is an internal function to build the frame containing the filter controls. This function should only be called from within the build function of a frame. The built widget has the following layout: +-------------------------------------------------------+ | filter_frame | ||+---------------------------------------------------+|| ||| ||| ||| ||| ||| ||| |||+-------------------------------------------------+||| |+-----------------------------------------------------+| +-------------------------------------------------------+ """ with omni.ui.VStack(): with omni.ui.HStack(): def _filter_changed(value): self.clear() self._filter_mode = value isaacsim.gui.components.ui_utils.dropdown_builder( label="Filter", type="dropdown", items=["None", "Lowpass", "Integrate", "Derivative"], tooltip="Select a filter", on_clicked_fn=_filter_changed, ) def _toggle_paused(): self._is_paused = not self._is_paused # Button omni.ui.Button( "Play/Pause", width=isaacsim.gui.components.ui_utils.BUTTON_WIDTH, clicked_fn=_toggle_paused, alignment=omni.ui.Alignment.LEFT_CENTER, style=isaacsim.gui.components.ui_utils.get_style(), ) def _create_ui_widget(self): """Create the full UI widget.""" def _build_widget(): self._is_built = False with omni.ui.VStack(): self._main_plot_frame = omni.ui.Frame(build_fn=self._build_stacked_plots) omni.ui.Spacer(height=8) self._legends_frame = omni.ui.Frame(build_fn=self._build_legends_frame) omni.ui.Spacer(height=8) self._limits_frame = omni.ui.Frame(build_fn=self._build_limits_frame) omni.ui.Spacer(height=8) self._filter_frame = omni.ui.Frame(build_fn=self._build_filter_frame) self._is_built = True containing_frame = omni.ui.Frame(build_fn=_build_widget) return containing_frame """ UI Actions Listener Functions """ def _change_plot_visibility(self, idx: int, visible: bool): """Change the visibility of a plot at position idx.""" self._series_visible[idx] = visible self._plot_frames[idx].visible = visible # self._main_plot_frame.rebuild() def _set_y_min(self, val: float): """Update the y-axis minimum.""" self._y_min = val self.lower_limit_drag.model.set_value(val) self._main_plot_frame.rebuild() def _set_y_max(self, val: float): """Update the y-axis maximum.""" self._y_max = val self.upper_limit_drag.model.set_value(val) self._main_plot_frame.rebuild() def _rescale_btn_pressed(self): """Autoscale the y-axis to the current data.""" if any(self._series_visible): y_min = np.round( min([min(y) for idx, y in enumerate(self._y_data) if self._series_visible[idx]]), 4, ) y_max = np.round( max([max(y) for idx, y in enumerate(self._y_data) if self._series_visible[idx]]), 4, ) if y_min == y_max: y_max += 1e-4 # Make sure axes don't collapse self._y_max = y_max self._y_min = y_min if hasattr(self, "lower_limit_drag") and hasattr(self, "upper_limit_drag"): self.lower_limit_drag.model.set_value(self._y_min) self.upper_limit_drag.model.set_value(self._y_max) self._main_plot_frame.rebuild() """ Helper Functions """ def _get_distinct_hex_colors(self, num_colors) -> list[int]: """ This function returns a list of distinct colors for plotting. Args: num_colors (int): the number of colors to generate Returns: List[int]: a list of distinct colors in hexadecimal format 0xFFBBGGRR """ # Generate equally spaced colors in HSV space rgb_colors = [ colorsys.hsv_to_rgb(hue / num_colors, 0.75, 1) for hue in np.linspace(0, num_colors - 1, num_colors) ] # Convert to 0-255 RGB values rgb_colors = [[int(c * 255) for c in rgb] for rgb in rgb_colors] # Convert to 0xFFBBGGRR format hex_colors = [0xFF * 16**6 + c[2] * 16**4 + c[1] * 16**2 + c[0] for c in rgb_colors] return hex_colors