Skip to content

LivePlot API

Real-time multi-trace plotting window powered by ImPlot.

Opens a separate GLFW window. The simulation loop must call :meth:update periodically to push new data and refresh the display.

Source code in bulletlab/plotting/live_plot.py
class LivePlot:
    """Real-time multi-trace plotting window powered by ImPlot.

    Opens a separate GLFW window. The simulation loop must call
    :meth:`update` periodically to push new data and refresh the display.
    """

    def __init__(
        self,
        title: str = "BulletLab Live Plot",
        max_points: int = 500,
        update_interval_ms: int = 33,
        y_label: str = "Value",
        x_label: str = "Time (s)",
    ) -> None:
        self._title = title
        self._max_points = max_points
        self._update_interval = update_interval_ms / 1000.0
        self._y_label = y_label
        self._x_label = x_label

        self._series: list[_PlotSeries] = []
        self._running = False
        self._paused = False
        self._start_time: float = 0.0
        self._last_refresh: float = 0.0

        # ImGui / GLFW objects
        self._window: Any = None
        self._impl: Any = None
        self._imgui_context: Any = None
        self._implot_context: Any = None

    def watch(
        self,
        name: str,
        source: Callable[[], Any],
        color: str = "#ffffff",
    ) -> "LivePlot":
        series = _PlotSeries(
            name=name,
            source=source,
            color=color,
            max_points=self._max_points,
        )
        self._series.append(series)
        return self

    def start(self) -> "LivePlot":
        if not _HAS_IMPLOT:
            print(
                "[BulletLab] LivePlot: imgui-bundle with implot is not available. "
                "Install with: pip install imgui-bundle glfw numpy"
            )
            return self

        if self._running:
            return self

        if not glfw.init():
            raise RuntimeError("GLFW could not be initialized")

        glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
        glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
        glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
        glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, 1)

        self._window = glfw.create_window(900, 500, self._title, None, None)
        if not self._window:
            glfw.terminate()
            raise RuntimeError("Could not create GLFW window")

        glfw.make_context_current(self._window)
        glfw.swap_interval(1)

        self._imgui_context = imgui.create_context()
        imgui.set_current_context(self._imgui_context)

        self._implot_context = implot.create_context()
        implot.set_current_context(self._implot_context)

        imgui.style_colors_dark()

        self._impl = GlfwRenderer(self._window)

        self._start_time = time.monotonic()
        self._last_refresh = self._start_time
        self._running = True
        return self

    def stop(self) -> None:
        self._running = False
        if self._impl is not None:
            self._impl.shutdown()
        if self._implot_context is not None:
            implot.destroy_context(self._implot_context)
        if self._imgui_context is not None:
            imgui.destroy_context(self._imgui_context)
        if self._window is not None:
            glfw.destroy_window(self._window)

        self._window = None
        self._impl = None
        self._imgui_context = None
        self._implot_context = None

    def update(self, t: float | None = None) -> None:
        if not self._running:
            return

        if glfw.window_should_close(self._window):
            self.stop()
            return

        if not self._paused:
            timestamp = t if t is not None else (time.monotonic() - self._start_time)

            for series in self._series:
                try:
                    val = float(series.source())
                except Exception:
                    val = float("nan")
                series.timestamps.append(timestamp)
                series.values.append(val)

        now = time.monotonic()
        if now - self._last_refresh < self._update_interval:
            return

        self._last_refresh = now
        self._refresh_display()

    def _refresh_display(self) -> None:
        if not _HAS_IMPLOT or self._window is None:
            return

        glfw.poll_events()
        self._impl.process_inputs()

        imgui.set_current_context(self._imgui_context)
        implot.set_current_context(self._implot_context)
        imgui.new_frame()

        # Render full screen window
        viewport = imgui.get_main_viewport()
        imgui.set_next_window_pos(viewport.pos)
        imgui.set_next_window_size(viewport.size)

        flags = (imgui.WindowFlags_.no_title_bar.value | 
                 imgui.WindowFlags_.no_resize.value | 
                 imgui.WindowFlags_.no_move.value | 
                 imgui.WindowFlags_.no_collapse.value |
                 imgui.WindowFlags_.no_bring_to_display_front.value)

        imgui.begin(self._title, True, flags)

        avail = imgui.get_content_region_available()

        if implot.begin_plot(self._title, avail):
            implot.setup_axes(self._x_label, self._y_label)

            for series in self._series:
                if len(series.timestamps) > 0:
                    x = np.array(list(series.timestamps), dtype=np.float64)
                    y = np.array(list(series.values), dtype=np.float64)

                    implot.set_next_line_style(hex_to_vec4(series.color), 2.0)
                    implot.plot_line(series.name, x, y)

            implot.end_plot()

        imgui.end()

        gl.glClearColor(0.1, 0.1, 0.1, 1)
        gl.glClear(gl.GL_COLOR_BUFFER_BIT)

        imgui.render()
        self._impl.render(imgui.get_draw_data())
        glfw.swap_buffers(self._window)

    def pause(self) -> None:
        self._paused = True

    def resume(self) -> None:
        self._paused = False

    def clear(self) -> None:
        for series in self._series:
            series.timestamps.clear()
            series.values.clear()

    def export(self, filepath: str) -> None:
        print("[BulletLab] LivePlot: image export is not natively supported in ImPlot yet. Please use ImPlot's right-click menu to take a screenshot.")

    @property
    def is_running(self) -> bool:
        return self._running

    @property
    def is_paused(self) -> bool:
        return self._paused

    @property
    def series_names(self) -> list[str]:
        return [s.name for s in self._series]

    def __repr__(self) -> str:
        status = "running" if self._running else "stopped"
        return f"LivePlot({self._title!r}, {status}, series={self.series_names})"