From fbc251c85814334653c6f864e31bbddd4c670f41 Mon Sep 17 00:00:00 2001 From: John Duggan Date: Fri, 31 Jan 2025 16:11:09 -0500 Subject: [PATCH 1/6] Add Plotly text --- code/episode_7/src/nova_tutorial/main.py | 2 +- .../src/nova_tutorial/models/plotly.py | 52 +++--- .../view_models/visualization.py | 17 +- .../src/nova_tutorial/views/plotly.py | 37 +++++ .../src/nova_tutorial/views/plotly_2d.py | 34 ---- .../views/{pyvista_3d.py => pyvista.py} | 2 +- .../src/nova_tutorial/views/visualization.py | 20 +-- .../nova_tutorial/views/{vtk_3d.py => vtk.py} | 2 +- episodes/07-Advanced-Visualizations.md | 151 ++++++++++++++++-- 9 files changed, 224 insertions(+), 93 deletions(-) create mode 100644 code/episode_7/src/nova_tutorial/views/plotly.py delete mode 100644 code/episode_7/src/nova_tutorial/views/plotly_2d.py rename code/episode_7/src/nova_tutorial/views/{pyvista_3d.py => pyvista.py} (98%) rename code/episode_7/src/nova_tutorial/views/{vtk_3d.py => vtk.py} (99%) diff --git a/code/episode_7/src/nova_tutorial/main.py b/code/episode_7/src/nova_tutorial/main.py index cfe12fdc..68a0571c 100644 --- a/code/episode_7/src/nova_tutorial/main.py +++ b/code/episode_7/src/nova_tutorial/main.py @@ -3,7 +3,7 @@ from nova_tutorial.views.visualization import VisualizationApp -def main(): +def main() -> None: app = VisualizationApp() app.server.start() diff --git a/code/episode_7/src/nova_tutorial/models/plotly.py b/code/episode_7/src/nova_tutorial/models/plotly.py index f76df468..d1450cf0 100644 --- a/code/episode_7/src/nova_tutorial/models/plotly.py +++ b/code/episode_7/src/nova_tutorial/models/plotly.py @@ -1,8 +1,7 @@ """Configuration for the Plotly example.""" -from typing import Any - -from plotly.data import iris # type: ignore +import plotly.graph_objects as go +from plotly.data import iris from pydantic import BaseModel, Field, computed_field IRIS_DATA = iris() @@ -11,35 +10,32 @@ IRIS_DATA = iris() class PlotlyConfig(BaseModel): """Configuration class for the Plotly example.""" - axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width", "species", "species_id"] + axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width"] x_axis: str = Field(default="sepal_length", title="X Axis") y_axis: str = Field(default="sepal_width", title="Y Axis") - z_axis: str = Field(default="petal_length", title="Z Axis") - plot_data: Any = None + z_axis: str = Field(default="petal_length", title="Color") plot_type: str = Field(default="scatter", title="Plot Type") plot_type_options: list[str] = ["heatmap", "scatter"] @computed_field # type: ignore @property - def is_scatter(self) -> bool: - return self.plot_type == "scatter" - - def update(self) -> None: - self.plot_data = { - "data": [ - { - "x": IRIS_DATA[self.x_axis].tolist(), - "y": IRIS_DATA[self.y_axis].tolist(), - "z": IRIS_DATA[self.z_axis].tolist(), - "type": self.plot_type, - "mode": "markers", - "colorbar": {"title": self.z_axis}, - "colorscale": "Viridis", - } - ], - "layout": { - "title": "Iris Flower Data Set", - "xaxis": {"fixedrange": True, "title": self.x_axis}, - "yaxis": {"fixedrange": True, "title": self.y_axis}, - }, - } + def is_not_heatmap(self) -> bool: + return self.plot_type != "heatmap" + + def get_figure(self) -> go.Figure: + match self.plot_type: + case "heatmap": + plot_data = go.Heatmap(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], z=IRIS_DATA[self.z_axis]) + case "scatter": + plot_data = go.Scatter(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], mode="markers") + case _: + raise ValueError(f"Invalid plot type: {self.plot_type}") + + figure = go.Figure(plot_data) + figure.update_layout( + title={"text": f"{self.plot_type}"}, + xaxis={"title": {"text": self.x_axis}}, + yaxis={"title": {"text": self.y_axis}}, + ) + + return figure diff --git a/code/episode_7/src/nova_tutorial/view_models/visualization.py b/code/episode_7/src/nova_tutorial/view_models/visualization.py index ac993ee1..882cfc68 100644 --- a/code/episode_7/src/nova_tutorial/view_models/visualization.py +++ b/code/episode_7/src/nova_tutorial/view_models/visualization.py @@ -14,7 +14,7 @@ from nova_tutorial.models.vtk import VTKConfig class Controls(BaseModel): """General controls for the GUI.""" - active_tab: int = Field(default=1) + active_tab: int = Field(default=0) class VisualizationViewModel: @@ -22,12 +22,15 @@ class VisualizationViewModel: def __init__(self, binding: BindingInterface): self.controls = Controls() - self.config_2d = PlotlyConfig() + self.plotly_config = PlotlyConfig() self.config_pyvista = PyVistaConfig() self.config_vtk = VTKConfig() self.controls_bind = binding.new_bind() - self.config_2d_bind = binding.new_bind(linked_object=self.config_2d, callback_after_update=self.update_2d_plot) + self.plotly_config_bind = binding.new_bind( + linked_object=self.plotly_config, callback_after_update=self.update_plotly + ) + self.plotly_figure_bind = binding.new_bind() self.config_pyvista_bind = binding.new_bind( linked_object=self.config_pyvista, callback_after_update=self.update_pyvista ) @@ -35,13 +38,13 @@ class VisualizationViewModel: def init_view(self) -> None: self.controls_bind.update_in_view(self.controls) - self.update_2d_plot() + self.update_plotly() self.update_pyvista() self.update_vtk() - def update_2d_plot(self, results: Optional[dict[str, Any]] = None) -> None: - self.config_2d.update() - self.config_2d_bind.update_in_view(self.config_2d) + def update_plotly(self, results: Optional[dict[str, Any]] = None) -> None: + self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) + self.plotly_config_bind.update_in_view(self.plotly_config) def update_pyvista(self, results: Optional[dict[str, Any]] = None) -> None: self.config_pyvista_bind.update_in_view(self.config_pyvista) diff --git a/code/episode_7/src/nova_tutorial/views/plotly.py b/code/episode_7/src/nova_tutorial/views/plotly.py new file mode 100644 index 00000000..b2e93f82 --- /dev/null +++ b/code/episode_7/src/nova_tutorial/views/plotly.py @@ -0,0 +1,37 @@ +"""View for Plotly.""" + +import plotly.graph_objects as go +from nova.trame.view.components import InputField +from nova.trame.view.layouts import GridLayout, HBoxLayout +from trame.widgets import plotly + +from nova_tutorial.view_models.visualization import VisualizationViewModel + + +class PlotlyView: + """View class for Plotly.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.plotly_config_bind.connect("plotly_config") + self.view_model.plotly_figure_bind.connect(self.update_figure) + + self.create_ui() + + def create_ui(self) -> None: + with GridLayout(columns=4, classes="mb-2"): + InputField(v_model="plotly_config.plot_type", items="plotly_config.plot_type_options", type="select") + InputField(v_model="plotly_config.x_axis", items="plotly_config.axis_options", type="select") + InputField(v_model="plotly_config.y_axis", items="plotly_config.axis_options", type="select") + InputField( + v_model="plotly_config.z_axis", + disabled=("plotly_config.is_not_heatmap",), + items="plotly_config.axis_options", + type="select", + ) + + with HBoxLayout(halign="center", height="50vh"): + self.figure = plotly.Figure() + + def update_figure(self, figure: go.Figure) -> None: + self.figure.update(figure) diff --git a/code/episode_7/src/nova_tutorial/views/plotly_2d.py b/code/episode_7/src/nova_tutorial/views/plotly_2d.py deleted file mode 100644 index 4fbb6ce4..00000000 --- a/code/episode_7/src/nova_tutorial/views/plotly_2d.py +++ /dev/null @@ -1,34 +0,0 @@ -"""View for the 2d plot.""" - -from nova.trame.view.components import InputField # type: ignore -from nova.trame.view.layouts import GridLayout, HBoxLayout # type: ignore -from trame.widgets import plotly # type: ignore -from trame.widgets import vuetify3 as vuetify - -from nova_tutorial.view_models.visualization import VisualizationViewModel - - -class Plot2D: - """View class for the 2d plot.""" - - def __init__(self, view_model: VisualizationViewModel) -> None: - self.view_model = view_model - self.view_model.config_2d_bind.connect("config_2d") - - self.create_ui() - - def create_ui(self) -> None: - vuetify.VCardTitle("Plotly") - with GridLayout(columns=4, classes="mb-2"): - InputField(v_model="config_2d.plot_type", items="config_2d.plot_type_options", type="select") - InputField(v_model="config_2d.x_axis", items="config_2d.axis_options", type="select") - InputField(v_model="config_2d.y_axis", items="config_2d.axis_options", type="select") - InputField( - v_model="config_2d.z_axis", - disabled=("config_2d.is_scatter",), - items="config_2d.axis_options", - type="select", - ) - - with HBoxLayout(halign="center", height="50vh"): - plotly.Figure(v_if="config_2d.plot_data", state_variable_name="config_2d.plot_data") diff --git a/code/episode_7/src/nova_tutorial/views/pyvista_3d.py b/code/episode_7/src/nova_tutorial/views/pyvista.py similarity index 98% rename from code/episode_7/src/nova_tutorial/views/pyvista_3d.py rename to code/episode_7/src/nova_tutorial/views/pyvista.py index 5965f961..d61a15ff 100644 --- a/code/episode_7/src/nova_tutorial/views/pyvista_3d.py +++ b/code/episode_7/src/nova_tutorial/views/pyvista.py @@ -12,7 +12,7 @@ from trame.widgets import vuetify3 as vuetify # type: ignore from nova_tutorial.view_models.visualization import VisualizationViewModel -class PyVistaPlot: +class PyVistaView: """View class for the 3d plot using PyVista.""" def __init__(self, view_model: VisualizationViewModel) -> None: diff --git a/code/episode_7/src/nova_tutorial/views/visualization.py b/code/episode_7/src/nova_tutorial/views/visualization.py index 49c91464..d7eed16c 100644 --- a/code/episode_7/src/nova_tutorial/views/visualization.py +++ b/code/episode_7/src/nova_tutorial/views/visualization.py @@ -8,9 +8,9 @@ from trame.app import get_server # type: ignore from trame.widgets import vuetify3 as vuetify # type: ignore from nova_tutorial.view_models.visualization import VisualizationViewModel -from nova_tutorial.views.plotly_2d import Plot2D -from nova_tutorial.views.pyvista_3d import PyVistaPlot -from nova_tutorial.views.vtk_3d import VTKPlot +from nova_tutorial.views.plotly import PlotlyView +from nova_tutorial.views.pyvista import PyVistaView +from nova_tutorial.views.vtk import VTKView class VisualizationApp(ThemedApp): # Inherits from nova.trame.ThemedApp for consistent styling @@ -40,19 +40,19 @@ class VisualizationApp(ThemedApp): # Inherits from nova.trame.ThemedApp for con with vuetify.VTabs( v_model="controls.active_tab", classes="pl-4", update_modelValue="flushState('controls');" ): - vuetify.VTab("Plotly", value=1) - vuetify.VTab("PyVista", value=2) - vuetify.VTab("VTK", value=3) + vuetify.VTab("Plotly", value=0) + vuetify.VTab("PyVista", value=1) + vuetify.VTab("VTK", value=2) with layout.content: with vuetify.VCard(): with vuetify.VTabsWindow(v_model="controls.active_tab"): + with vuetify.VTabsWindowItem(value=0): + PlotlyView(self.view_model) with vuetify.VTabsWindowItem(value=1): - Plot2D(self.view_model) + PyVistaView(self.view_model) with vuetify.VTabsWindowItem(value=2): - PyVistaPlot(self.view_model) - with vuetify.VTabsWindowItem(value=3): - VTKPlot(self.view_model) + VTKView(self.view_model) layout.post_content.classes += "mb-4" diff --git a/code/episode_7/src/nova_tutorial/views/vtk_3d.py b/code/episode_7/src/nova_tutorial/views/vtk.py similarity index 99% rename from code/episode_7/src/nova_tutorial/views/vtk_3d.py rename to code/episode_7/src/nova_tutorial/views/vtk.py index e5c1bed5..1f761870 100644 --- a/code/episode_7/src/nova_tutorial/views/vtk_3d.py +++ b/code/episode_7/src/nova_tutorial/views/vtk.py @@ -9,7 +9,7 @@ from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow, vtkRenderW from nova_tutorial.view_models.visualization import VisualizationViewModel -class VTKPlot: +class VTKView: """View class for the 3d plot using PyVista.""" def __init__(self, view_model: VisualizationViewModel) -> None: diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index eaaf49c9..0f7d41f0 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -1,25 +1,154 @@ --- title: "Advanced Visualizations" teaching: 10 -exercises: 0 +exercises: 1 --- # Advanced Visualizations -Here we talk about advanced Trame visualizations! -## Plotly 2D Plot -Time resolve viz? +In this section, we will look at a selection of the libraries that integrate well with Trame for producing more sophisticated visualizations of your data. Specifically, we will look at Plotly for interactive 2D charts, PyVista for interactive 3D visualizations, and VTK for advanced 3D visualizations. -## Pyvista 3d visualizations +The complete code for this episode is available in the `code/episode_7` directory. This code defines a Trame application that presents three views (one each for Plotly, PyVista, and VTK) that the user can choose between with a tab widget. -## VTK? +## Plotly (2D) -Focus on how these connect in trame +Trame provides a library called [trame-plotly](https://github.com/Kitware/trame-plotly) for connecting Trame and [Plotly](https://plotly.com/python/). You can install it with: +```bash +poetry add trame-plotly +``` + +Now, we can create a view that displays a Plotly figure. + +**1. `PlotlyView` View Class (`src/nova_tutorial/views/plotly.py`):** + +* **Imports**: Pay special attention to the plotly import. This module contains a Trame widget that will allow us to quickly add a Plotly chart to our view. + + ```python + """View for Plotly.""" + + import plotly.graph_objects as go + from nova.trame.view.components import InputField + from nova.trame.view.layouts import GridLayout, HBoxLayout + from trame.widgets import plotly + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Class Definition**: The view model connections allow us to connect the controls we will define in create_ui() to the server and update the Plotly chart after a control is changed. + + ```python + class PlotlyView: + """View class for Plotly.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.plotly_config_bind.connect("plotly_config") + self.view_model.plotly_figure_bind.connect(self.update_figure) + + self.create_ui() + ``` + +* **Controls**: These controls will dynamically update the Plotly chart. + + ```python + def create_ui(self) -> None: + with GridLayout(columns=4, classes="mb-2"): + InputField(v_model="plotly_config.plot_type", items="plotly_config.plot_type_options", type="select") + InputField(v_model="plotly_config.x_axis", items="plotly_config.axis_options", type="select") + InputField(v_model="plotly_config.y_axis", items="plotly_config.axis_options", type="select") + InputField( + v_model="plotly_config.z_axis", + disabled=("plotly_config.is_not_heatmap",), + items="plotly_config.axis_options", + type="select", + ) + ``` + +* **Chart Definition**: Here, we use the imported Trame widget for Plotly to define the chart. This widget includes an `update` method that allows us to change the content after the initial rendering. + + ```python + with HBoxLayout(halign="center", height="50vh"): + self.figure = plotly.Figure() + + def update_figure(self, figure: go.Figure) -> None: + self.figure.update(figure) + ``` + +As with our previous examples, there is a corresponding model. + +**2. `PlotlyConfig` Model Class (src/nova_tutorial/models/plotly.py):** + +* **Imports**: The graph_objects module is how we will define the content for our chart. The iris module defines an example dataset. + + ```python + """Configuration for the Plotly example.""" + + import plotly.graph_objects as go + from plotly.data import iris + from pydantic import BaseModel, Field, computed_field + + IRIS_DATA = iris() + ``` + +* **Pydantic definition**: Here we define the controls for our view. + + ```python + class PlotlyConfig(BaseModel): + """Configuration class for the Plotly example.""" + + axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width"] + x_axis: str = Field(default="sepal_length", title="X Axis") + y_axis: str = Field(default="sepal_width", title="Y Axis") + z_axis: str = Field(default="petal_length", title="Color") + plot_type: str = Field(default="scatter", title="Plot Type") + plot_type_options: list[str] = ["heatmap", "scatter"] + + @computed_field # type: ignore + @property + def is_not_heatmap(self) -> bool: + return self.plot_type != "heatmap" + ``` + +* **Plotly Figure Setup**: Finally, we define the Plotly figure based on the user's selection. go.Heatmap and go.Scatter define Plotly `traces`, which represent individual components of the figure. + + ```python + def get_figure(self) -> go.Figure: + match self.plot_type: + case "heatmap": + plot_data = go.Heatmap(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], z=IRIS_DATA[self.z_axis]) + case "scatter": + plot_data = go.Scatter(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], mode="markers") + case _: + raise ValueError(f"Invalid plot type: {self.plot_type}") + + figure = go.Figure(plot_data) + figure.update_layout( + title={"text": f"{self.plot_type}"}, + xaxis={"title": {"text": self.x_axis}}, + yaxis={"title": {"text": self.y_axis}}, + ) + + return figure + ``` + +As with our other examples, the view model connects these two classes together. You can review the view model code for this example in `src/nova_tutorial/view_models/visualization.py`. + +## PyVista (3D) + +## VTK (3D) + +## Exercises + +1. **Plotly Box Plot:** Add a box plot to the available plot types. Hint: you shouldn't need to change anything in the view class to do this. +2. **PyVista clim Control:** Add control(s) to the UI to control the `clim` argument for the `add_volume` method. +3. **Change the VTK Dataset:** Using https://docs.pyvista.org/api/examples/dataset_gallery, select a different 3D volume dataset. You will need to replace the VTKSLCReader with an appropriate reader for your selected data and change the min/max based on the scalar values of your selected dataset. ## References -* **Nova Documentation**: https://nova-application-development.readthedocs.io/en/latest/ -* **nova-galaxy documentation**: https://nova-application-development.readthedocs.io/projects/nova-galaxy/en/latest/ -* **nova-trame documentation**: https://nova-application-development.readthedocs.io/projects/nova-trame/en/stable/ -* **nova-mvvm documentation**: https://nova-application-development.readthedocs.io/projects/mvvm-lib/en/latest/ \ No newline at end of file +* **Plotly Documentation**: https://plotly.com/python/ +* **Trame/Plotly Integration Repository**: https://github.com/Kitware/trame-plotly +* **PyVista Documentation**: https://docs.pyvista.org/ +* **Trame/PyVista Integration Tutorial**: https://tutorial.pyvista.org/tutorial/09_trame/index.html +* **VTK Python Documentation**: https://docs.vtk.org/en/latest/api/python.html +* **Trame Tutorial**: https://kitware.github.io/trame/guide/tutorial/ -- GitLab From c49a6cf609b3a8441474a44d4e84f9f1f84b9672 Mon Sep 17 00:00:00 2001 From: John Duggan Date: Mon, 3 Feb 2025 08:49:40 -0500 Subject: [PATCH 2/6] Test removing update_in_view for config data --- code/episode_7/poetry.lock | 6 +++--- code/episode_7/pyproject.toml | 2 +- .../nova_tutorial/view_models/visualization.py | 17 +++++------------ 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/code/episode_7/poetry.lock b/code/episode_7/poetry.lock index e4a55833..29511c16 100755 --- a/code/episode_7/poetry.lock +++ b/code/episode_7/poetry.lock @@ -1410,12 +1410,12 @@ tomli = ">=2.0.2,<3.0.0" [[package]] name = "nova-mvvm" -version = "0.8.0" +version = "0.9.0" description = "A Python Package for Model-View-ViewModel pattern" optional = false python-versions = "<4.0,>=3.10" files = [ - {file = "nova_mvvm-0.8.0-py3-none-any.whl", hash = "sha256:494e87915785dee46f01d06bc723dd29fba1724042fdf7fa102789a2e524d055"}, + {file = "nova_mvvm-0.9.0-py3-none-any.whl", hash = "sha256:60c70f8579b155e7081548e8aec9e77de497fa33ac569096015cffe27ef0f796"}, ] [package.dependencies] @@ -3003,4 +3003,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f9931d7baca4d3cc5354430155449d43e5364af4fa95648bd39137bb8b05f375" +content-hash = "5cb22adddafd5b2416c7533855cfdaac1ca49f636272dcf41713f76fceb65da1" diff --git a/code/episode_7/pyproject.toml b/code/episode_7/pyproject.toml index 706779ab..4f452f8a 100644 --- a/code/episode_7/pyproject.toml +++ b/code/episode_7/pyproject.toml @@ -15,7 +15,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" nova-galaxy = "^0.4.0" -nova-mvvm = "0.8.0" +nova-mvvm = "0.9.0" nova-trame = "0.14.0" pandas = "^2.2.3" plotly = "^5.24.1" diff --git a/code/episode_7/src/nova_tutorial/view_models/visualization.py b/code/episode_7/src/nova_tutorial/view_models/visualization.py index 882cfc68..4fec68c4 100644 --- a/code/episode_7/src/nova_tutorial/view_models/visualization.py +++ b/code/episode_7/src/nova_tutorial/view_models/visualization.py @@ -28,26 +28,19 @@ class VisualizationViewModel: self.controls_bind = binding.new_bind() self.plotly_config_bind = binding.new_bind( - linked_object=self.plotly_config, callback_after_update=self.update_plotly + linked_object=self.plotly_config, callback_after_update=self.update_plotly_figure ) self.plotly_figure_bind = binding.new_bind() - self.config_pyvista_bind = binding.new_bind( - linked_object=self.config_pyvista, callback_after_update=self.update_pyvista - ) - self.config_vtk_bind = binding.new_bind() + self.config_pyvista_bind = binding.new_bind(linked_object=self.config_pyvista) + self.config_vtk_bind = binding.new_bind(linked_object=self.config_vtk) def init_view(self) -> None: self.controls_bind.update_in_view(self.controls) - self.update_plotly() - self.update_pyvista() + self.update_plotly_figure() self.update_vtk() - def update_plotly(self, results: Optional[dict[str, Any]] = None) -> None: + def update_plotly_figure(self, results: Optional[dict[str, Any]] = None) -> None: self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) - self.plotly_config_bind.update_in_view(self.plotly_config) - - def update_pyvista(self, results: Optional[dict[str, Any]] = None) -> None: - self.config_pyvista_bind.update_in_view(self.config_pyvista) def render_pyvista(self, plotter: Optional[Plotter]) -> None: self.config_pyvista.update(plotter) -- GitLab From 2775f7c459919d6462575ebf798460bd5cb24fef Mon Sep 17 00:00:00 2001 From: John Duggan Date: Mon, 3 Feb 2025 09:43:14 -0500 Subject: [PATCH 3/6] Clean up most lingering regrets (except for potential Trame bugs) from examples --- .../src/nova_tutorial/models/pyvista.py | 13 ++++------ .../episode_7/src/nova_tutorial/models/vtk.py | 19 +++++++------- .../view_models/visualization.py | 25 +++++++++--------- .../src/nova_tutorial/views/pyvista.py | 26 ++++++++++--------- .../src/nova_tutorial/views/visualization.py | 8 +++--- code/episode_7/src/nova_tutorial/views/vtk.py | 12 ++++----- 6 files changed, 52 insertions(+), 51 deletions(-) diff --git a/code/episode_7/src/nova_tutorial/models/pyvista.py b/code/episode_7/src/nova_tutorial/models/pyvista.py index 4c9e1297..02c40ed1 100644 --- a/code/episode_7/src/nova_tutorial/models/pyvista.py +++ b/code/episode_7/src/nova_tutorial/models/pyvista.py @@ -1,7 +1,5 @@ """Configuration for the PyVista example.""" -from typing import Optional - from pydantic import BaseModel, Field from pyvista import Plotter, examples @@ -16,12 +14,11 @@ class PyVistaConfig(BaseModel): colormap: str = Field(default="viridis", title="Color Transfer Function") opacity: str = Field(default="linear", title="Opacity Transfer Function") - def update(self, plotter: Optional[Plotter]) -> None: + def render(self, plotter: Plotter) -> None: # TODO: This should not need to fully re-render each time. However, something about this setup is making # view.update() not work. - if plotter: - plotter.clear() - plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) + plotter.clear() + plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) - plotter.render() - plotter.view_isometric() # type: ignore + plotter.render() + plotter.view_isometric() diff --git a/code/episode_7/src/nova_tutorial/models/vtk.py b/code/episode_7/src/nova_tutorial/models/vtk.py index 36f3d4ac..c2b4bf04 100644 --- a/code/episode_7/src/nova_tutorial/models/vtk.py +++ b/code/episode_7/src/nova_tutorial/models/vtk.py @@ -2,36 +2,37 @@ import numpy as np from pyvista import examples -from vtk import vtkSLCReader # type: ignore +from vtk import vtkSLCReader from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction from vtkmodules.vtkRenderingCore import vtkColorTransferFunction, vtkVolume, vtkVolumeProperty from vtkmodules.vtkRenderingVolume import vtkFixedPointVolumeRayCastMapper +KNEE_DATA = examples.download_knee_full() +KNEE_DATAFILE = examples.download_knee_full(load=False) + class VTKConfig: """Configuration class for the VTK example.""" - # The min/max here are somewhat arbitrary values that work well with this dataset. - max: float = 200.0 - min: float = 75.0 + max: float = KNEE_DATA.get_data_range()[1] + min: float = KNEE_DATA.get_data_range()[0] def __init__(self) -> None: - datafile = examples.download_knee_full(load=False) reader = vtkSLCReader() - reader.SetFileName(datafile) + reader.SetFileName(KNEE_DATAFILE) mapper = vtkFixedPointVolumeRayCastMapper() mapper.SetInputConnection(reader.GetOutputPort()) lut = self.init_lut() pwf = self.init_pwf() - volume_props = vtkVolumeProperty() # type: ignore + volume_props = vtkVolumeProperty() volume_props.SetColor(lut) volume_props.SetScalarOpacity(pwf) volume_props.SetShade(0) volume_props.SetInterpolationTypeToLinear() - self.volume = vtkVolume() # type: ignore + self.volume = vtkVolume() self.volume.SetMapper(mapper) self.volume.SetProperty(volume_props) self.volume.SetVisibility(1) @@ -89,7 +90,7 @@ class VTKConfig: ] ) - for arr in np.split(srgb, len(srgb) / 4): # type: ignore + for arr in np.split(srgb, len(srgb) / 4): lut.AddRGBPoint(arr[0], arr[1], arr[2], arr[3]) prev_min, prev_max = lut.GetRange() diff --git a/code/episode_7/src/nova_tutorial/view_models/visualization.py b/code/episode_7/src/nova_tutorial/view_models/visualization.py index 4fec68c4..a0964a23 100644 --- a/code/episode_7/src/nova_tutorial/view_models/visualization.py +++ b/code/episode_7/src/nova_tutorial/view_models/visualization.py @@ -2,7 +2,7 @@ from typing import Any, Optional -from nova.mvvm.interface import BindingInterface # type: ignore +from nova.mvvm.interface import BindingInterface from pydantic import BaseModel, Field from pyvista import Plotter @@ -23,27 +23,28 @@ class VisualizationViewModel: def __init__(self, binding: BindingInterface): self.controls = Controls() self.plotly_config = PlotlyConfig() - self.config_pyvista = PyVistaConfig() - self.config_vtk = VTKConfig() + self.pyvista_config = PyVistaConfig() + self.vtk_config = VTKConfig() self.controls_bind = binding.new_bind() self.plotly_config_bind = binding.new_bind( linked_object=self.plotly_config, callback_after_update=self.update_plotly_figure ) self.plotly_figure_bind = binding.new_bind() - self.config_pyvista_bind = binding.new_bind(linked_object=self.config_pyvista) - self.config_vtk_bind = binding.new_bind(linked_object=self.config_vtk) + self.pyvista_config_bind = binding.new_bind(linked_object=self.pyvista_config) + self.vtk_config_bind = binding.new_bind(linked_object=self.vtk_config) + self.render_vtk_bind = binding.new_bind() def init_view(self) -> None: self.controls_bind.update_in_view(self.controls) self.update_plotly_figure() - self.update_vtk() + self.init_vtk() - def update_plotly_figure(self, results: Optional[dict[str, Any]] = None) -> None: - self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) + def init_vtk(self) -> None: + self.render_vtk_bind.update_in_view(self.vtk_config.get_volume()) - def render_pyvista(self, plotter: Optional[Plotter]) -> None: - self.config_pyvista.update(plotter) + def render_pyvista(self, plotter: Plotter) -> None: + self.pyvista_config.render(plotter) - def update_vtk(self) -> None: - self.config_vtk_bind.update_in_view(self.config_vtk.get_volume()) + def update_plotly_figure(self, _: Optional[dict[str, Any]] = None) -> None: + self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) diff --git a/code/episode_7/src/nova_tutorial/views/pyvista.py b/code/episode_7/src/nova_tutorial/views/pyvista.py index d61a15ff..d7dea3a7 100644 --- a/code/episode_7/src/nova_tutorial/views/pyvista.py +++ b/code/episode_7/src/nova_tutorial/views/pyvista.py @@ -1,13 +1,12 @@ """View for the 3d plot using PyVista.""" -from functools import partial -from typing import Optional +from typing import Any, Optional -import pyvista as pv # type: ignore -from nova.trame.view.components import InputField # type: ignore -from nova.trame.view.layouts import GridLayout, HBoxLayout # type: ignore -from pyvista.trame.ui import plotter_ui # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +import pyvista as pv +from nova.trame.view.components import InputField +from nova.trame.view.layouts import GridLayout, HBoxLayout +from pyvista.trame.ui import plotter_ui +from trame.widgets import vuetify3 as vuetify from nova_tutorial.view_models.visualization import VisualizationViewModel @@ -17,7 +16,7 @@ class PyVistaView: def __init__(self, view_model: VisualizationViewModel) -> None: self.view_model = view_model - self.view_model.config_pyvista_bind.connect("config_pyvista") + self.view_model.pyvista_config_bind.connect("pyvista_config") self.plotter: Optional[pv.Plotter] = None @@ -31,12 +30,15 @@ class PyVistaView: vuetify.VCardTitle("PyVista") with GridLayout(columns=5, classes="mb-2", valign="center"): InputField( - v_model="config_pyvista.colormap", column_span=2, items="config_pyvista.colormap_options", type="select" + v_model="pyvista_config.colormap", column_span=2, items="pyvista_config.colormap_options", type="select" ) InputField( - v_model="config_pyvista.opacity", column_span=2, items="config_pyvista.opacity_options", type="select" + v_model="pyvista_config.opacity", column_span=2, items="pyvista_config.opacity_options", type="select" ) - vuetify.VBtn("Render", click=partial(self.view_model.render_pyvista, self.plotter)) - + vuetify.VBtn("Render", click=self.update) with HBoxLayout(halign="center", height="50vh"): plotter_ui(self.plotter) + + def update(self, _: Any = None) -> None: + if self.plotter: + self.view_model.render_pyvista(self.plotter) diff --git a/code/episode_7/src/nova_tutorial/views/visualization.py b/code/episode_7/src/nova_tutorial/views/visualization.py index d7eed16c..ca34ca0a 100644 --- a/code/episode_7/src/nova_tutorial/views/visualization.py +++ b/code/episode_7/src/nova_tutorial/views/visualization.py @@ -2,10 +2,10 @@ from typing import Any -from nova.mvvm.trame_binding import TrameBinding # type: ignore -from nova.trame import ThemedApp # type: ignore -from trame.app import get_server # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +from nova.mvvm.trame_binding import TrameBinding +from nova.trame import ThemedApp +from trame.app import get_server +from trame.widgets import vuetify3 as vuetify from nova_tutorial.view_models.visualization import VisualizationViewModel from nova_tutorial.views.plotly import PlotlyView diff --git a/code/episode_7/src/nova_tutorial/views/vtk.py b/code/episode_7/src/nova_tutorial/views/vtk.py index 1f761870..ee19e11d 100644 --- a/code/episode_7/src/nova_tutorial/views/vtk.py +++ b/code/episode_7/src/nova_tutorial/views/vtk.py @@ -1,9 +1,9 @@ """View for the 3d plot using PyVista.""" import vtkmodules.vtkRenderingVolumeOpenGL2 # noqa -from nova.trame.view.layouts import HBoxLayout # type: ignore -from trame.widgets import vtk as vtkw # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +from nova.trame.view.layouts import HBoxLayout +from trame.widgets import vtk as vtkw +from trame.widgets import vuetify3 as vuetify from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, vtkVolume from nova_tutorial.view_models.visualization import VisualizationViewModel @@ -14,13 +14,13 @@ class VTKView: def __init__(self, view_model: VisualizationViewModel) -> None: self.view_model = view_model - self.view_model.config_vtk_bind.connect(self.render) + self.view_model.render_vtk_bind.connect(self.render) self.create_vtk() self.create_ui() def create_vtk(self) -> None: - self.renderer = vtkRenderer() # type: ignore + self.renderer = vtkRenderer() self.renderer.SetBackground(0.7, 0.7, 0.7) self.render_window = vtkRenderWindow() @@ -29,7 +29,7 @@ class VTKView: self.render_window_interactor = vtkRenderWindowInteractor() self.render_window_interactor.SetRenderWindow(self.render_window) - self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() # type: ignore + self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() self.render_window_interactor.Initialize() # Ensure interactor is initialized def create_ui(self) -> None: -- GitLab From 309eefc8518527a912723079f66335aa50e62438 Mon Sep 17 00:00:00 2001 From: John Duggan Date: Mon, 3 Feb 2025 10:52:40 -0500 Subject: [PATCH 4/6] Add text for PyVista and VTK tutorials --- episodes/07-Advanced-Visualizations.md | 225 ++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 2 deletions(-) diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index 0f7d41f0..91bf865d 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -15,7 +15,7 @@ The complete code for this episode is available in the `code/episode_7` director Trame provides a library called [trame-plotly](https://github.com/Kitware/trame-plotly) for connecting Trame and [Plotly](https://plotly.com/python/). You can install it with: ```bash -poetry add trame-plotly +poetry add plotly trame-plotly ``` Now, we can create a view that displays a Plotly figure. @@ -136,13 +136,234 @@ As with our other examples, the view model connects these two classes together. ## PyVista (3D) +One of Trame's core features is that it has direct integration with VTK for building 3D visualizations. Learning VTK from scratch is non-trivial, however, so we recommend that you work with PyVista. PyVista serves as a more developer-friendly wrapper around VTK, allowing you to build your visualizations with a simpler, more intuitive API. To get started, you will need to install the Python package. + +```bash +poetry add pyvista trame-vtk +``` + +PyVista contains built-in Trame support, but we still need to install the Trame widget for VTK that PyVista will use internally. + +Now we can set up our view. + +**3. `PyVistaView` View Class (`src/nova_tutorial/views/pyvista.py`):** + +* **Imports:** `plotter_ui` contains the Trame widget for PyVista. + + ```python + """View for the 3d plot using PyVista.""" + + from typing import Any, Optional + + import pyvista as pv + from nova.trame.view.components import InputField + from nova.trame.view.layouts import GridLayout, HBoxLayout + from pyvista.trame.ui import plotter_ui + from trame.widgets import vuetify3 as vuetify + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Class Definition:** The `Plotter` object is PyVista's main entry point. It will allow you to add meshes and volumes with the properties you've specified. + + ```python + class PyVistaView: + """View class for the 3d plot using PyVista.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.pyvista_config_bind.connect("pyvista_config") + + self.plotter: Optional[pv.Plotter] = None + + self.create_plotter() + self.create_ui() + + def create_plotter(self) -> None: + self.plotter = pv.Plotter(off_screen=True) + ``` + +* **View Definition:** Now, we can use `plotter_ui` to create a view into which our rendering will go. + + ```python + def create_ui(self) -> None: + vuetify.VCardTitle("PyVista") + with GridLayout(columns=5, classes="mb-2", valign="center"): + InputField( + v_model="pyvista_config.colormap", column_span=2, items="pyvista_config.colormap_options", type="select" + ) + InputField( + v_model="pyvista_config.opacity", column_span=2, items="pyvista_config.opacity_options", type="select" + ) + vuetify.VBtn("Render", click=self.update) + with HBoxLayout(halign="center", height="50vh"): + plotter_ui(self.plotter) + + def update(self, _: Any = None) -> None: + if self.plotter: + self.view_model.render_pyvista(self.plotter) + ``` + +**4. `PyVistaConfig` Model Class (`src/nova_tutorial/models/pyvista.py`):** + +* **Imports:** `download_knee_full` yields a 3D dataset that is suitable for volume rendering. You can find more datasets in PyVista's [Dataset Gallery](https://docs.pyvista.org/api/examples/dataset_gallery). + + ```python + """Configuration for the PyVista example.""" + + from pydantic import BaseModel, Field + from pyvista import Plotter, examples + + KNEE_DATA = examples.download_knee_full() + ``` + +* **Pydantic Configuration:** The `Fields` defined here will be passed to [`Plotter.add_volume`](https://docs.pyvista.org/api/plotting/_autosummary/pyvista.plotter.add_volume). + + ```python + class PyVistaConfig(BaseModel): + """Configuration class for the PyVista example.""" + + colormap_options: list[str] = ["viridis", "autumn", "coolwarm", "twilight", "jet"] + opacity_options: list[str] = ["linear", "sigmoid"] + colormap: str = Field(default="viridis", title="Color Transfer Function") + opacity: str = Field(default="linear", title="Opacity Transfer Function") + ``` + +* **Rendering:** `add_volume` will return an actor. In practice, you may get better performance by manipulating that actor instead of doing a full re-render. + + ```python + def render(self, plotter: Plotter) -> None: + plotter.clear() + plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) + + plotter.render() + plotter.view_isometric() + ``` + ## VTK (3D) +If you have prior experience with VTK then you may prefer to work with it directly. You can get started with it by installing the Python VTK bindings and the Trame widget for VTK. + +```bash +poetry add trame-vtk vtk +``` + +Since we've seen plenty of examples of UI controls at this point, we've omitted them for this example so that we can focus on the VTK boilerplate needed to get started. + +**5. `VTKView` View Class (`src/nova_tutorial/views/vtk.py`):** + +* **Imports:** The `vtkRenderingVolumeOpenGL2` import is necessary despite being unreferenced. + + ```python + """View for the 3d plot using PyVista.""" + + import vtkmodules.vtkRenderingVolumeOpenGL2 # noqa + from nova.trame.view.layouts import HBoxLayout + from trame.widgets import vtk as vtkw + from trame.widgets import vuetify3 as vuetify + from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, vtkVolume + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Initialization:** Here we define the boiler plate for the interactive VTK window. As with PyVista, setting off-screen rendering to on is necessary when working with Trame. + + ```python + class VTKView: + """View class for the 3d plot using PyVista.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.render_vtk_bind.connect(self.render) + + self.create_vtk() + self.create_ui() + + def create_vtk(self) -> None: + self.renderer = vtkRenderer() + self.renderer.SetBackground(0.7, 0.7, 0.7) + + self.render_window = vtkRenderWindow() + self.render_window.AddRenderer(self.renderer) + self.render_window.OffScreenRenderingOn() + + self.render_window_interactor = vtkRenderWindowInteractor() + self.render_window_interactor.SetRenderWindow(self.render_window) + self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.render_window_interactor.Initialize() # Ensure interactor is initialized + ``` + +* **View Definition:** Now, we setup the VTK window and add our volume rendering to it. By using `VTKRemoteView`, we are instructing VTK to perform server-side rendering. + + ```python + def create_ui(self) -> None: + vuetify.VCardTitle("VTK") + + with HBoxLayout(halign="center", height="50vh"): + self.view = vtkw.VtkRemoteView(self.render_window, interactive_ratio=1) + + def render(self, volume: vtkVolume) -> None: + self.renderer.Clear() + self.renderer.AddVolume(volume) + self.render_window.Render() + ``` + +**6. `VTKConfig` Model Class (`src/nova_tutorial/models/vtk.py`):** + +* **Imports:** We are only using PyVista to get an example dataset. There are two references to it as we use `KNEE_DATA` to compute min/max bounds for the data and `KNEE_DATAFILE` to pass the data file into a VTK reader. The FixedPointVolumeRayCastMapper is CPU-based, but other mappers are available if you need GPU support. + + ```python + """Configuration for the VTK example.""" + + import numpy as np + from pyvista import examples + from vtk import vtkSLCReader + from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction + from vtkmodules.vtkRenderingCore import vtkColorTransferFunction, vtkVolume, vtkVolumeProperty + from vtkmodules.vtkRenderingVolume import vtkFixedPointVolumeRayCastMapper + + KNEE_DATA = examples.download_knee_full() + KNEE_DATAFILE = examples.download_knee_full(load=False) + ``` + +* **VTK Pipeline Setup:** The dataset is stored in .slc format, so we can use a built-in VTK reader to load it into a pipeline. From there, we setup the volume. A lookup table is used to define the color transfer function, and a piecewise function is used to define the opacity transfer function. + + ```python + class VTKConfig: + """Configuration class for the VTK example.""" + + max: float = KNEE_DATA.get_data_range()[1] + min: float = KNEE_DATA.get_data_range()[0] + + def __init__(self) -> None: + reader = vtkSLCReader() + reader.SetFileName(KNEE_DATAFILE) + + mapper = vtkFixedPointVolumeRayCastMapper() + mapper.SetInputConnection(reader.GetOutputPort()) + + lut = self.init_lut() + pwf = self.init_pwf() + volume_props = vtkVolumeProperty() + volume_props.SetColor(lut) + volume_props.SetScalarOpacity(pwf) + volume_props.SetShade(0) + volume_props.SetInterpolationTypeToLinear() + + self.volume = vtkVolume() + self.volume.SetMapper(mapper) + self.volume.SetProperty(volume_props) + self.volume.SetVisibility(1) + + def get_volume(self) -> vtkVolume: + return self.volume + ``` + ## Exercises 1. **Plotly Box Plot:** Add a box plot to the available plot types. Hint: you shouldn't need to change anything in the view class to do this. 2. **PyVista clim Control:** Add control(s) to the UI to control the `clim` argument for the `add_volume` method. -3. **Change the VTK Dataset:** Using https://docs.pyvista.org/api/examples/dataset_gallery, select a different 3D volume dataset. You will need to replace the VTKSLCReader with an appropriate reader for your selected data and change the min/max based on the scalar values of your selected dataset. +3. **Investigate the lookup table and piecewise function:** We didn't look at `VTKConfig.init_lut` or `VTKConfig.init_pwf` during the tutorial. Read through these methods and then trys manipulating the opacity of the rendering. ## References -- GitLab From 4c3fed48cd479e2e0e883ddddecb7ced7dcfe1dc Mon Sep 17 00:00:00 2001 From: John Duggan Date: Mon, 3 Feb 2025 15:36:00 -0500 Subject: [PATCH 5/6] Add note on volume rendering limitations (ie why I fully re-render the scene on changes) --- code/episode_7/src/nova_tutorial/models/pyvista.py | 4 ++-- episodes/07-Advanced-Visualizations.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/code/episode_7/src/nova_tutorial/models/pyvista.py b/code/episode_7/src/nova_tutorial/models/pyvista.py index 02c40ed1..3e174c7b 100644 --- a/code/episode_7/src/nova_tutorial/models/pyvista.py +++ b/code/episode_7/src/nova_tutorial/models/pyvista.py @@ -15,8 +15,8 @@ class PyVistaConfig(BaseModel): opacity: str = Field(default="linear", title="Opacity Transfer Function") def render(self, plotter: Plotter) -> None: - # TODO: This should not need to fully re-render each time. However, something about this setup is making - # view.update() not work. + # If re-rendering the volume on changes isn't acceptable, then you may need to switch to using VTK directly due + # limitations of the PyVista volume rendering engine. plotter.clear() plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index 91bf865d..03daf22a 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -233,6 +233,8 @@ Now we can set up our view. ```python def render(self, plotter: Plotter) -> None: + # If re-rendering the volume on changes isn't acceptable, then you may need to switch to using VTK directly due + # limitations of the PyVista volume rendering engine. plotter.clear() plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) -- GitLab From db03fbf4d9868ace480981d4a188f79fd5f1b923 Mon Sep 17 00:00:00 2001 From: John Duggan Date: Mon, 3 Feb 2025 15:45:14 -0500 Subject: [PATCH 6/6] Fix plotly updates --- code/episode_7/src/nova_tutorial/view_models/visualization.py | 4 ++-- code/episode_7/src/nova_tutorial/views/plotly.py | 1 + episodes/07-Advanced-Visualizations.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/code/episode_7/src/nova_tutorial/view_models/visualization.py b/code/episode_7/src/nova_tutorial/view_models/visualization.py index a0964a23..99d1c854 100644 --- a/code/episode_7/src/nova_tutorial/view_models/visualization.py +++ b/code/episode_7/src/nova_tutorial/view_models/visualization.py @@ -26,7 +26,7 @@ class VisualizationViewModel: self.pyvista_config = PyVistaConfig() self.vtk_config = VTKConfig() - self.controls_bind = binding.new_bind() + self.controls_bind = binding.new_bind(self.controls) self.plotly_config_bind = binding.new_bind( linked_object=self.plotly_config, callback_after_update=self.update_plotly_figure ) @@ -36,7 +36,6 @@ class VisualizationViewModel: self.render_vtk_bind = binding.new_bind() def init_view(self) -> None: - self.controls_bind.update_in_view(self.controls) self.update_plotly_figure() self.init_vtk() @@ -47,4 +46,5 @@ class VisualizationViewModel: self.pyvista_config.render(plotter) def update_plotly_figure(self, _: Optional[dict[str, Any]] = None) -> None: + self.plotly_config_bind.update_in_view(self.plotly_config) self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) diff --git a/code/episode_7/src/nova_tutorial/views/plotly.py b/code/episode_7/src/nova_tutorial/views/plotly.py index b2e93f82..3be3b0e6 100644 --- a/code/episode_7/src/nova_tutorial/views/plotly.py +++ b/code/episode_7/src/nova_tutorial/views/plotly.py @@ -35,3 +35,4 @@ class PlotlyView: def update_figure(self, figure: go.Figure) -> None: self.figure.update(figure) + self.figure.state.flush() # This is necessary if you call update asynchronously. diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index 03daf22a..a3fa26d1 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -73,6 +73,7 @@ Now, we can create a view that displays a Plotly figure. def update_figure(self, figure: go.Figure) -> None: self.figure.update(figure) + self.figure.state.flush() # This is necessary if you call update asynchronously. ``` As with our previous examples, there is a corresponding model. -- GitLab