diff --git a/.gitignore b/.gitignore
index 9bc885a33..4eed25643 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ src/viser/client/build
src/viser/client/.nodeenv
**/.claude/settings.local.json
+.venv
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 834275808..17a6dc9a4 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -97,4 +97,4 @@ URL (default: ``http://localhost:8080``).
:alt: Version icon
:target: https://pypi.org/project/viser/
.. |nbsp| unicode:: 0xA0
- :trim:
\ No newline at end of file
+ :trim:
diff --git a/docs/source/scene_handles.rst b/docs/source/scene_handles.rst
index 012a87022..c844daf34 100644
--- a/docs/source/scene_handles.rst
+++ b/docs/source/scene_handles.rst
@@ -64,4 +64,4 @@ methods like :func:`viser.ViserServer.add_frame()` or
.. autoclass:: viser.RectAreaLightHandle
-.. autoclass:: viser.SpotLightHandle
\ No newline at end of file
+.. autoclass:: viser.SpotLightHandle
diff --git a/examples/assets/download_colmap_garden.sh b/examples/assets/download_colmap_garden.sh
index 63f93046a..5f2da5bfd 100755
--- a/examples/assets/download_colmap_garden.sh
+++ b/examples/assets/download_colmap_garden.sh
@@ -8,4 +8,4 @@ gdown "https://drive.google.com/uc?id=1wYHdrgwXPHtREdCjItvt4gqRQGISMade"
mkdir -p colmap_garden
# shellcheck disable=SC2035
-unzip *.zip && rm *.zip
\ No newline at end of file
+unzip *.zip && rm *.zip
diff --git a/live_plots.py b/live_plots.py
new file mode 100644
index 000000000..4080e38bf
--- /dev/null
+++ b/live_plots.py
@@ -0,0 +1,125 @@
+"""Live Plotly Plots in Viser
+
+Example of creating live-updating Plotly plots in Viser."""
+
+import time
+
+import numpy as np
+import plotly.graph_objects as go
+
+import viser
+
+# handle the modal plot DONE
+# handle the main plot reanchoring
+# handle multiple trajectories
+# handles number of elements in history DONE
+# handle boundary ylims, xlims
+# rename functions
+
+
+def create_wave_plot(t: float, wave_type: str = "sin") -> go.Figure:
+ """Create a wave plot starting at time t."""
+ x_data = np.linspace(t, t + 0.1 * np.pi, 50)
+ if wave_type == "sin":
+ y_data = np.sin(60 * x_data) * 10
+ title = "Sine Wave"
+ else:
+ y_data = np.cos(60 * x_data) * 10
+ title = "Cosine Wave"
+
+ fig = go.Figure()
+ fig.add_trace(
+ go.Scatter(
+ x=list(x_data),
+ y=list(10 + y_data),
+ mode="lines",
+ line=dict(color="red", width=2), # Thinner line
+ fill="tozeroy",
+ fillcolor="rgba(255, 0, 0, 0.2)",
+ name=wave_type,
+ )
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=list(x_data),
+ y=list(y_data),
+ mode="lines",
+ line=dict(color="blue", width=2), # Thinner line
+ # fill="tozeroy",
+ # fillcolor="rgba(0, 0, 255, 0.2)",
+ name=wave_type + "_2",
+ )
+ )
+
+ fig.update_layout(
+ title=title,
+ xaxis_title="x",
+ yaxis_title=f"{wave_type}(x)",
+ margin=dict(l=20, r=20, t=40, b=20),
+ showlegend=False,
+ # yaxis=dict(range=[-15, 15]),
+ xaxis=dict(autorange=False),
+ yaxis=dict(autorange=False),
+ )
+
+ return fig
+
+
+def main() -> None:
+ server = viser.ViserServer()
+
+ Nfull = 40
+ Nupdate = 100000
+ time_step = 0.1
+ Nchunk = 1
+
+ # Create two plots
+ time_value = 0.0
+ sin_plot_handle = server.gui.add_plotly(
+ figure=create_wave_plot(time_value, "sin"), aspect=0.75
+ )
+ cos_plot_handle = server.gui.add_plotly(
+ figure=create_wave_plot(time_value, "cos"), aspect=0.75
+ )
+
+ # while True:
+ for i in range(Nfull):
+ print("i", i, "of", Nfull)
+ sin_plot_handle.figure = create_wave_plot(time_value, "sin")
+ cos_plot_handle.figure = create_wave_plot(time_value, "cos")
+
+ time.sleep(time_step)
+ time_value += time_step
+
+ for i in range(Nupdate):
+ t0 = time.time()
+
+ x_data = time_value + time_step * np.arange(Nchunk) / Nchunk
+ x_data = np.tile(x_data, (2, 1))
+ y_data = 10 * np.sin(5 * x_data) + np.array(
+ [5 * np.ones(Nchunk), np.zeros(Nchunk)]
+ )
+
+ server.gui.plotly_extend_traces(
+ plotly_element_uuids=[
+ cos_plot_handle._impl.uuid,
+ sin_plot_handle._impl.uuid,
+ ],
+ x_data=x_data,
+ y_data=y_data,
+ history_length=10,
+ )
+
+ print("cos_plot_handle", cos_plot_handle._impl.uuid)
+ print("sin_plot_handle", sin_plot_handle._impl.uuid)
+ t1 = time.time()
+ elapsed = t1 - t0
+ print("elapsed", elapsed)
+ time.sleep(time_step)
+ time_value += time_step
+
+ input("Press Enter to continue...")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py
index e14e0770f..86c0a5a06 100644
--- a/src/viser/_gui_api.py
+++ b/src/viser/_gui_api.py
@@ -17,6 +17,7 @@
Sequence,
Tuple,
TypeVar,
+ Union,
cast,
overload,
)
@@ -432,9 +433,9 @@ def configure_theme(
if brand_color is not None:
assert len(brand_color) in (3, 10)
if len(brand_color) == 3:
- assert all(map(lambda val: isinstance(val, int), brand_color)), (
- "All channels should be integers."
- )
+ assert all(
+ map(lambda val: isinstance(val, int), brand_color)
+ ), "All channels should be integers."
# RGB => HLS.
h, l, s = colorsys.rgb_to_hls(
@@ -709,28 +710,11 @@ def add_image(
handle.image = image
return handle
- def add_plotly(
- self,
- figure: go.Figure,
- aspect: float = 1.0,
- order: float | None = None,
- visible: bool = True,
- ) -> GuiPlotlyHandle:
- """Add a Plotly figure to the GUI. Requires the `plotly` package to be
- installed.
-
- Args:
- figure: Plotly figure to display.
- aspect: Aspect ratio of the plot in the control panel (width/height).
- order: Optional ordering, smallest values will be displayed first.
- visible: Whether the component is visible.
-
- Returns:
- A handle that can be used to interact with the GUI element.
+ def setup_plotly_js(self) -> None:
+ """
+ If plotly.min.js hasn't been sent to the client yet, the client won't be able
+ to render the plot. Send this large file now! (~3MB)
"""
-
- # If plotly.min.js hasn't been sent to the client yet, the client won't be able
- # to render the plot. Send this large file now! (~3MB)
if not self._setup_plotly_js:
# Check if plotly is installed.
try:
@@ -744,9 +728,9 @@ def add_plotly(
plotly_path = (
Path(plotly.__file__).parent / "package_data" / "plotly.min.js"
)
- assert plotly_path.exists(), (
- f"Could not find plotly.min.js at {plotly_path}."
- )
+ assert (
+ plotly_path.exists()
+ ), f"Could not find plotly.min.js at {plotly_path}."
# Send it over!
plotly_js = plotly_path.read_text(encoding="utf-8")
@@ -757,7 +741,29 @@ def add_plotly(
# Update the flag so we don't send it again.
self._setup_plotly_js = True
+ def add_plotly(
+ self,
+ figure: go.Figure,
+ aspect: float = 1.0,
+ order: float | None = None,
+ visible: bool = True,
+ ) -> GuiPlotlyHandle:
+ """Add a Plotly figure to the GUI. Requires the `plotly` package to be
+ installed.
+
+ Args:
+ figure: Plotly figure to display.
+ aspect: Aspect ratio of the plot in the control panel (width/height).
+ order: Optional ordering, smallest values will be displayed first.
+ visible: Whether the component is visible.
+
+ Returns:
+ A handle that can be used to interact with the GUI element.
+ """
+
+ self.setup_plotly_js()
# After plotly.min.js has been sent, we can send the plotly figure.
+
# Empty string for `plotly_json_str` is a signal to the client to render nothing.
message = _messages.GuiPlotlyMessage(
uuid=_make_uuid(),
@@ -785,8 +791,41 @@ def add_plotly(
# Set the plotly handle properties.
handle.figure = figure
handle.aspect = aspect
+
return handle
+ def plotly_extend_traces(
+ self,
+ plotly_element_uuids: list[str],
+ x_data: list[float] | list[list[float]] | np.ndarray,
+ y_data: list[float] | list[list[float]] | np.ndarray,
+ history_length: int,
+ ) -> None:
+ """Extend traces in a plotly plot with new data.
+
+ Args:
+ plotly_element_uuids: UUIDs of the plotly elements to update
+ x_data: X-axis data. Can be a 1D list/array for single trace or 2D list/array for multiple traces
+ y_data: Y-axis data. Can be a 1D list/array for single trace or 2D list/array for multiple traces
+ history_length: Number of points to keep in the history
+ """
+ # Create a unique message for each update
+ message = _messages.GuiPlotlyExtendTracesMessage(
+ # uuid=_make_uuid(),
+ container_uuid=self._get_container_uuid(),
+ props=_messages.GuiPlotlyExtendTracesProps(
+ plotly_element_uuids=plotly_element_uuids,
+ x_data=self.to_list_of_lists(x_data),
+ y_data=self.to_list_of_lists(y_data),
+ history_length=history_length,
+ ),
+ )
+ # Ensure the message is queued with a unique key
+ # message.redundancy_key = lambda: f"plotly-extend-{plotly_element_uuid}-{id(message)}"
+ print("redundancy_key", message.redundancy_key())
+ self._websock_interface.queue_message(message)
+ # self._websock_interface.flush()
+
def add_button(
self,
label: str,
@@ -1649,3 +1688,23 @@ def sync_other_clients(
handle_state.sync_cb = sync_other_clients
return handle_state
+
+ def to_list_of_lists(self, x: Union[Sequence, np.ndarray]) -> list[list[float]]:
+ """Convert input to a list of list of floats."""
+ if isinstance(x, np.ndarray):
+ arr = x.astype(float)
+ if arr.ndim == 1:
+ return [arr.tolist()] # Wrap 1D array
+ elif arr.ndim == 2:
+ return arr.tolist()
+ else:
+ raise ValueError(f"Unsupported ndarray with ndim={arr.ndim}")
+ elif isinstance(x, (list, tuple)):
+ if all(isinstance(el, (int, float)) for el in x):
+ return [[float(val) for val in x]] # 1D list
+ elif all(isinstance(el, (list, tuple)) for el in x):
+ return [[float(val) for val in row] for row in x] # 2D list
+ else:
+ raise ValueError("List must contain only numbers or lists of numbers.")
+ else:
+ raise TypeError(f"Unsupported input type: {type(x)}")
diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py
index ce1843cfa..b3b640c43 100644
--- a/src/viser/_gui_handles.py
+++ b/src/viser/_gui_handles.py
@@ -779,6 +779,7 @@ def figure(self, figure: go.Figure) -> None:
json_str = figure.to_json()
assert isinstance(json_str, str)
+ # print(json_str)
self._plotly_json_str = json_str
diff --git a/src/viser/_messages.py b/src/viser/_messages.py
index f85fff48a..626f8474e 100644
--- a/src/viser/_messages.py
+++ b/src/viser/_messages.py
@@ -1011,6 +1011,25 @@ class GuiPlotlyMessage(_CreateGuiComponentMessage):
props: GuiPlotlyProps
+@dataclasses.dataclass
+class GuiPlotlyExtendTracesProps:
+ plotly_element_uuids: list[str]
+ """UUIDs of the plotly elements to update."""
+ x_data: list[float]
+ """List of x-data points for each trace."""
+ y_data: list[float]
+ """List of y-data points for each trace."""
+ history_length: int
+ """History length for the plot."""
+
+
+@dataclasses.dataclass
+class GuiPlotlyExtendTracesMessage(Message):
+ # uuid: str
+ container_uuid: str
+ props: GuiPlotlyExtendTracesProps
+
+
@dataclasses.dataclass
class GuiImageProps:
order: float
diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py
index 7b8e3d417..76b54442a 100644
--- a/src/viser/_scene_api.py
+++ b/src/viser/_scene_api.py
@@ -108,9 +108,9 @@ def _encode_image_binary(
def cast_vector(vector: TVector | np.ndarray, length: int) -> TVector:
if not isinstance(vector, tuple):
- assert cast(np.ndarray, vector).shape == (length,), (
- f"Expected vector of shape {(length,)}, but got {vector.shape} instead"
- )
+ assert cast(np.ndarray, vector).shape == (
+ length,
+ ), f"Expected vector of shape {(length,)}, but got {vector.shape} instead"
return cast(TVector, tuple(map(float, vector)))
@@ -1107,9 +1107,9 @@ def add_point_cloud(
Handle for manipulating scene node.
"""
colors_cast = colors_to_uint8(np.asarray(colors))
- assert len(points.shape) == 2 and points.shape[-1] == 3, (
- "Shape of points should be (N, 3)."
- )
+ assert (
+ len(points.shape) == 2 and points.shape[-1] == 3
+ ), "Shape of points should be (N, 3)."
assert colors_cast.shape in {
points.shape,
(3,),
diff --git a/src/viser/client/public/logo.svg b/src/viser/client/public/logo.svg
index 7a6bfe30e..f98c7bdd9 100644
--- a/src/viser/client/public/logo.svg
+++ b/src/viser/client/public/logo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx
index 6ae486928..4459879c9 100644
--- a/src/viser/client/src/ControlPanel/Generated.tsx
+++ b/src/viser/client/src/ControlPanel/Generated.tsx
@@ -16,7 +16,7 @@ import RgbComponent from "../components/Rgb";
import RgbaComponent from "../components/Rgba";
import ButtonGroupComponent from "../components/ButtonGroup";
import MarkdownComponent from "../components/Markdown";
-import PlotlyComponent from "../components/PlotlyComponent";
+import PlotlyComponent, { PlotlyExtendTracesComponent } from "../components/PlotlyComponent";
import TabGroupComponent from "../components/TabGroup";
import FolderComponent from "../components/Folder";
import MultiSliderComponent from "../components/MultiSlider";
@@ -95,6 +95,12 @@ function GeneratedInput(props: { guiUuid: string }) {
console.error("Tried to render non-existent component", props.guiUuid);
return null;
}
+
+ // console.log("%c[GeneratedInput] Rendering component", "color: #2196F3; font-weight: bold", {
+ // type: conf.type,
+ // uuid: props.guiUuid
+ // });
+
switch (conf.type) {
case "GuiFolderMessage":
return