diff --git a/src/pan3d/custom/contour.py b/src/pan3d/custom/contour.py index 8f30c2b..7cc76bc 100644 --- a/src/pan3d/custom/contour.py +++ b/src/pan3d/custom/contour.py @@ -25,9 +25,9 @@ ) from pan3d.ui.css import base, preview -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.convert import to_float, to_image from pan3d.utils.presets import PRESETS, set_preset +from pan3d.widgets.pan3d_view import Pan3DView from trame.app import get_server from trame.decorators import TrameApp, change from trame.ui.vuetify3 import VAppLayout diff --git a/src/pan3d/explorers/analytics.py b/src/pan3d/explorers/analytics.py index 05cd502..3787aec 100644 --- a/src/pan3d/explorers/analytics.py +++ b/src/pan3d/explorers/analytics.py @@ -23,14 +23,13 @@ plot_options, zonal_axes, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -373,107 +372,43 @@ def _build_ui(self, **kwargs): self.state.setdefault("time_groups", 0) self.state.setdefault("figure_height", 50) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) + ## New way to build UI + with StandardExplorerLayout( + explorer=self, title="Analytics Explorer" + ) as self.ui: + with self.ui.content: + Pan3dAnalyticsView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: + RenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) - with v3.VLayout(): - with v3.VMain(style="position: relative"): - with html.Div( - style="position: relative; width: 100%; height: 100%;", - ): - # 3D view - Pan3dAnalyticsView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Analytics Explorer", - ).ui_content: - RenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) - - with v3.VNavigationDrawer( - disable_resize_watcher=True, - disable_route_watcher=True, - permanent=True, - location="right", - v_model=("plot_drawer", False), - width=800, - ): - self.plotting = Plotting( - source=self.source, toggle="chart_expanded" - ) + with v3.VNavigationDrawer( + disable_resize_watcher=True, + disable_route_watcher=True, + permanent=True, + location="right", + v_model=("plot_drawer", False), + width=800, + ): + self.plotting = Plotting(source=self.source, toggle="chart_expanded") + + self.ctx.save_dialog.save_callback = self._save_dataset + if self.source and self.source.input is not None: + self.ctx.rendering.update_from_source(self.source) # ----------------------------------------------------- # State change callbacks # ----------------------------------------------------- - @change("color_by") - def _on_color_by_change(self, **kwargs): + @change("color_by", "color_preset", "color_min", "color_max", "nan_color") + def _on_color_properties_change(self, **kwargs): super()._on_color_properties_change(**kwargs) self.plotting.update_plot() diff --git a/src/pan3d/explorers/contour.py b/src/pan3d/explorers/contour.py index dd55ef1..fd64440 100644 --- a/src/pan3d/explorers/contour.py +++ b/src/pan3d/explorers/contour.py @@ -24,14 +24,12 @@ ) from pan3d.ui.contour import ContourRenderingSettings -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.ui.layouts import StandardExplorerLayout +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 class ContourExplorer(Explorer): @@ -45,10 +43,6 @@ def __init__( input=self.xarray ) # To initialize the pipeline - # setup - self.last_field = None - self.last_preset = None - self._setup_vtk(pipeline) self._build_ui() @@ -134,80 +128,16 @@ def _build_ui(self, **_): "scale_z": 0.01, } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Contour Explorer", - ).ui_content: + + # Use the standard UI creation method + with StandardExplorerLayout(explorer=self, title="Contour Explorer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: ContourRenderingSettings( ctx_name="rendering", source=self.source, @@ -215,12 +145,20 @@ def _build_ui(self, **_): ) def update_rendering(self, reset_camera=False): - self.renderer.ResetCamera() + self.state.dirty_data = False - if self.local_rendering: - self.ctrl.view_update(push_camera=True) + # Ensure actors are visible + if self.actor.GetVisibility() == 0: + self.actor.SetVisibility(1) + if self.actor_lines.GetVisibility() == 0: + self.actor_lines.SetVisibility(1) - self.ctrl.view_reset_camera() + self.renderer.ResetCamera() + + if reset_camera: + self.ctrl.view_reset_camera() + else: + self.ctrl.view_update() # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/explorers/globe.py b/src/pan3d/explorers/globe.py index 7bd9aed..8e5d0ba 100644 --- a/src/pan3d/explorers/globe.py +++ b/src/pan3d/explorers/globe.py @@ -19,14 +19,12 @@ from pan3d.filters.globe import ProjectToSphere from pan3d.ui.globe import GlobeRenderingSettings -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.ui.layouts import StandardExplorerLayout +from pan3d.utils.common import Explorer from pan3d.utils.globe import get_continent_outlines, get_globe, get_globe_textures -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 # Prevent view-up warning vtkObject.GlobalWarningDisplayOff() @@ -120,82 +118,15 @@ def _build_ui(self, **kwargs): "render_shadow": False, } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - disable_style_toggle=True, - disable_roll=True, - disable_axis_align=True, - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Globe Explorer", - ).ui_content: + # Use the standard UI creation method + with StandardExplorerLayout(explorer=self, title="Globe Explorer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: GlobeRenderingSettings( ctx_name="rendering", source=self.source, diff --git a/src/pan3d/explorers/slicer.py b/src/pan3d/explorers/slicer.py index 9c20398..79b740b 100644 --- a/src/pan3d/explorers/slicer.py +++ b/src/pan3d/explorers/slicer.py @@ -24,13 +24,12 @@ vtkRenderWindowInteractor, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.slicer import SliceRenderingSettings -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.utils.common import Explorer +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -77,60 +76,81 @@ def __init__(self, render_window, **kwargs): ) -class SliceSummary(html.Div): - def __init__(self, **kwargs): - super().__init__(**kwargs) +class SliceSummary(v3.VCard): + def __init__( + self, + axis_names="axis_names", + slice_axis="slice_axis", + cut_x="cut_x", + cut_y="cut_y", + cut_z="cut_z", + bounds="bounds", + **kwargs, + ): + super().__init__( + classes="slice-summary", + rounded="pill", + **kwargs, + ) + with self: - html.Div( - "{{slice_axis}}", - classes="text-subtitle-1 text-capitalize text-left", - style="transform-origin: 50% 50%; transform: rotate(-90deg) translateX(-100%) translateY(-1rem); position: absolute;", - ) - html.Div( - "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", - classes="text-subtitle-1 mx-1", - ) - v3.VSlider( - v_show="slice_axis === slice_axes[0]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_x",), - min=("bounds[0]",), - max=("bounds[1]",), - ) - v3.VSlider( - v_show="slice_axis === slice_axes[1]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_y",), - min=("bounds[2]",), - max=("bounds[3]",), - ) - v3.VSlider( - v_show="slice_axis === slice_axes[2]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_z",), - min=("bounds[4]",), - max=("bounds[5]",), - ) - - html.Div( - "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2]).toFixed(2)}}", - classes="text-subtitle-1 mx-1", - ) + with v3.VRow(classes="align-center mx-2 my-0"): + v3.VSelect( + v_model=(slice_axis,), + items=(axis_names,), + hide_details=True, + density="compact", + variant="solo", + flat=True, + max_width=100, + ) + v3.VSpacer() + html.Span( + "{{parseFloat(cut_x).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[0]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_y).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[1]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_z).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[2]", + classes="text-subtitle-1", + ) + with v3.VRow(classes="mx-2 my-0"): + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[0]", + v_model=(cut_x,), + min=(f"{bounds}[0]",), + max=(f"{bounds}[1]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[1]", + v_model=(cut_y,), + min=(f"{bounds}[2]",), + max=(f"{bounds}[3]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[2]", + v_model=(cut_z,), + min=(f"{bounds}[4]",), + max=(f"{bounds}[5]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) class SliceExplorer(Explorer): @@ -245,87 +265,20 @@ def _build_ui(self, *args, **kwargs): "slice_axis": "Z", } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DSlicerView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Sliders overlay - SliceSummary( - v_if="!control_expended", - classes="d-flex align-center flex-column", - style="position: absolute; left: 0; top: 10%; bottom: 10%; z-index: 2; pointer-events: none; min-width: 5rem;", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Slice Explorer", - ).ui_content: + + # Use the standard UI creation method + with StandardExplorerLayout(explorer=self, title="Slice Explorer") as self.ui: + with self.ui.content: + Pan3DSlicerView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + SliceSummary( + v_show="!control_expended", + style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); z-index: 2;", + ) + with self.ui.control_panel: SliceRenderingSettings( ctx_name="rendering", source=self.source, @@ -333,12 +286,20 @@ def _build_ui(self, *args, **kwargs): ) def update_rendering(self, reset_camera=False): - self.renderer.ResetCamera() + self.state.dirty_data = False + + # Ensure actors are visible + if self.slice_actor.GetVisibility() == 0: + self.slice_actor.SetVisibility(1) + if self.data_actor.GetVisibility() == 0 and self.state.tdata: + self.data_actor.SetVisibility(1) - if self.local_rendering: - self.ctrl.view_update(push_camera=True) + self.renderer.ResetCamera() - self.ctrl.view_reset_camera() + if reset_camera: + self.ctrl.view_reset_camera() + else: + self.ctrl.view_update() # ------------------------------------------------------------------------- # Property API @@ -359,27 +320,6 @@ def slice_axis(self, axis: str) -> None: with self.state: self.state.slice_axis = axis - @property - def slice_value(self): - """ - Returns the value(origin) for the dimension along which the slice - is performed - """ - s = self.state - axis = "xyz"[s.slice_axes.index(s.slice_axis)] - return s[f"cut_{axis}"] - - @slice_value.setter - def slice_value(self, value: float) -> None: - """ - Sets the value(origin) for the dimension along which the slice - is performed - """ - with self.state: - s = self.state - axis = "xyz"[s.slice_axes.index(s.slice_axis)] - s[f"cut_{axis}"] = value - @property def view_mode(self): """ @@ -396,22 +336,6 @@ def view_mode(self, mode): with self.state: self.state.view_mode = mode - @property - def scale_axis(self): - s = self.state - return [s.x_scale, s.y_scale, s.z_scale] - - @scale_axis.setter - def scale_axis(self, sfac): - s = self.state - s.x_scale = float(sfac[0]) - s.y_scale = float(sfac[1]) - s.z_scale = float(sfac[2]) - self.slice_actor.SetScale(*sfac) - self.data_actor.SetScale(*sfac) - self.outline_actor.SetScale(*sfac) - self.on_view_mode_change(s.view_mode) - # ------------------------------------------------------------------------- # UI triggers # ------------------------------------------------------------------------- diff --git a/src/pan3d/ui/contour.py b/src/pan3d/ui/contour.py index f6ccd77..09cd9bf 100644 --- a/src/pan3d/ui/contour.py +++ b/src/pan3d/ui/contour.py @@ -1,7 +1,6 @@ -import math - -from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length +from pan3d.ui.rendering_settings import RenderingSettingsBasic +from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -14,62 +13,20 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", + axis_names_var="axis_names", + default_value=1, + min_value=0.001, + max_value=100, + step=0.1, + classes="mx-2 my-2", + ) # contours with v3.VTooltip( @@ -93,28 +50,17 @@ def __init__(self, source, update_rendering, **kwargs): ) v3.VDivider() - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${time_idx + 1} / ${slice_t_max+1}`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() + # Update button v3.VBtn( "Update 3D view", block=True, @@ -138,13 +84,7 @@ def update_from_source(self, source=None): state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if self.ctx.has("time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/control_panel.py b/src/pan3d/ui/control_panel.py new file mode 100644 index 0000000..59e54f9 --- /dev/null +++ b/src/pan3d/ui/control_panel.py @@ -0,0 +1,166 @@ +"""Control panel UI component for Pan3D explorers.""" + +from pan3d.widgets.data_information import DataInformation +from pan3d.widgets.data_origin import DataOrigin +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ControlPanel(v3.VCard): + """ + Control panel component that provides data selection, import/export, + and dataset information display. + + This panel includes: + - Data origin selection (when enabled) + - Import/export state functionality + - Save dataset to disk + - Data information display + """ + + def __init__( + self, + enable_data_selection, + toggle, + load_dataset, + import_file_upload, + export_file_download, + xr_update_info="xr_update_info", + panel_label="XArray Viewer", + **kwargs, + ): + """ + Initialize the ControlPanel. + + Parameters: + enable_data_selection: Whether to show data selection UI + toggle: State variable name for panel expansion toggle + load_dataset: Callback function for loading datasets + import_file_upload: Callback function for importing state files + export_file_download: Callback function for exporting state + xr_update_info: Controller method name for updating data info + panel_label: Label to display in the panel header + **kwargs: Additional arguments passed to VCard + """ + super().__init__( + classes="controller", + rounded=(f"{toggle} || 'circle'",), + **kwargs, + ) + + # state initialization + self.state.import_pending = False + + # extract trigger name + download_export = self.ctrl.trigger_name(export_file_download) + + with self: + with v3.VCardTitle( + classes=( + f"`d-flex pa-1 position-fixed bg-white ${{ {toggle} ? 'controller-content rounded-t border-b-thin':'rounded-circle'}}`", + ), + style="z-index: 1;", + ): + v3.VProgressLinear( + v_if=toggle, + indeterminate=("trame__busy",), + bg_color="rgba(0,0,0,0)", + absolute=True, + color="primary", + location="bottom", + height=2, + ) + v3.VProgressCircular( + v_else=True, + bg_color="rgba(0,0,0,0)", + indeterminate=("trame__busy",), + style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;", + color="primary", + width=3, + ) + v3.VBtn( + icon="mdi-close", + v_if=toggle, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + v3.VBtn( + icon="mdi-menu", + v_else=True, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + if self.server.hot_reload: + v3.VBtn( + v_show=toggle, + icon="mdi-refresh", + flat=True, + size="sm", + click=self.ctrl.on_server_reload, + ) + v3.VSpacer() + html.Div( + panel_label, + v_show=toggle, + classes="text-h6 px-2", + ) + v3.VSpacer() + + with v3.VMenu(v_if=toggle, density="compact"): + with html.Template(v_slot_activator="{props}"): + v3.VBtn( + v_bind="props", + icon="mdi-file-arrow-left-right-outline", + flat=True, + size="sm", + classes="mx-1", + ) + with v3.VList(density="compact"): + if enable_data_selection: + with v3.VListItem( + title="Export state file", + disabled=("can_load",), + click=f"utils.download('xarray-state.json', trigger('{download_export}'), 'text/plain')", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon( + "mdi-cloud-download-outline", classes="mr-n5" + ) + + with v3.VListItem( + title="Import state file", + click="trame.utils.get('document').querySelector('#fileImport').click()", + ): + html.Input( + id="fileImport", + hidden=True, + type="file", + change=( + import_file_upload, + "[$event.target.files]", + ), + __events=["change"], + ) + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-cloud-upload-outline", classes="mr-n5") + v3.VDivider() + with v3.VListItem( + title="Save dataset to disk", + disabled=("can_load",), + click="show_save_dialog = true", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-file-download-outline", classes="mr-n5") + + with v3.VCardText( + v_show=(toggle, True), + classes="controller-content py-1 mt-10", + ) as ui_content: + self.ui_content = ui_content + if enable_data_selection: + DataOrigin(load_dataset) + + self._data_info = DataInformation() + self.ctrl[xr_update_info] = self._data_info.update_information diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index 7ffbede..c9e7c90 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,8 +1,8 @@ -import math - -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.utils.convert import max_str_length +from pan3d.widgets import ClipSliceControl +from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -12,6 +12,7 @@ def __init__(self, source, update_rendering, **kwargs): super().__init__(source, update_rendering, **kwargs) self.source = source + self.state.setdefault("dataset_bounds", [0, 1, 0, 1, 0, 1]) with self.content: v3.VDivider() @@ -113,221 +114,43 @@ def __init__(self, source, update_rendering, **kwargs): ) v3.VDivider() - - # X crop/cut - with v3.VTooltip( - v_if="axis_names?.[0]", - text=( - "`${axis_names[0]}: [${dataset_bounds[0]}, ${dataset_bounds[1]}] ${slice_x_type ==='range' ? ('(' + slice_x_range.map((v,i) => v+1).concat(slice_x_step).join(', ') + ')'): slice_x_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[0]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_x_type === 'range'", - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_range", None), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_cut", 0), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_x_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) - - # Y crop/cut - with v3.VTooltip( - v_if="axis_names?.[1]", - text=( - "`${axis_names[1]}: [${dataset_bounds[2]}, ${dataset_bounds[3]}] ${slice_y_type ==='range' ? ('(' + slice_y_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_y_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[1]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_y_type === 'range'", - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_range", None), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_cut", 0), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_y_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) - - # Z crop/cut - with v3.VTooltip( - v_if="axis_names?.[2]", - text=( - "`${axis_names[2]}: [${dataset_bounds[4]}, ${dataset_bounds[5]}] ${slice_z_type ==='range' ? ('(' + slice_z_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_z_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_z_type === 'range'", - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_range", None), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_cut", 0), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_z_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + # Clip/Slice controls for all axes + ClipSliceControl( + axis_names_var="axis_names", + dataset_bounds_var="dataset_bounds", + slice_extents_var="slice_extents", + type_vars=["slice_x_type", "slice_y_type", "slice_z_type"], + range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], + cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], + step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], + ctx_name="clip_slice", + ) v3.VDivider() - # Slice steps - with v3.VTooltip(text="Level Of Details / Slice stepping"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-stairs", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model_number=("slice_x_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model_number=("slice_y_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model_number=("slice_z_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - # Time slider - with v3.VTooltip( + # Level of detail / Slice steps + VectorPropertyControl( + property_name="step", + icon="mdi-stairs", + tooltip="Level Of Details / Slice stepping", + x_name="slice_x_step", + y_name="slice_y_step", + z_name="slice_z_step", + axis_names_var="axis_names", + default_value=1, + min_value=1, + classes="mx-2 my-2", + ) + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${t_labels[slice_t]} (${slice_t+1}/${slice_t_max+1})`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() + # Update button v3.VBtn( "Update 3D view", block=True, @@ -350,36 +173,22 @@ def update_from_source(self, source=None): state.color_by = None state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents - slices = source.slices - for axis in XYZ: - # default - axis_extent = state.slice_extents.get(getattr(source, axis)) - state[f"slice_{axis}_range"] = axis_extent - state[f"slice_{axis}_cut"] = 0 - state[f"slice_{axis}_step"] = 1 - state[f"slice_{axis}_type"] = "range" - # use slice info if available - axis_slice = slices.get(getattr(source, axis)) - if axis_slice is not None: - if isinstance(axis_slice, int): - # cut - state[f"slice_{axis}_cut"] = axis_slice - state[f"slice_{axis}_type"] = "cut" - else: - # range - state[f"slice_{axis}_range"] = [ - axis_slice[0], - axis_slice[1] - 1, - ] # end is inclusive - state[f"slice_{axis}_step"] = axis_slice[2] + # Update dataset bounds for each axis + bounds = [] + for axis in XYZ: + axis_name = getattr(source, axis) + if axis_name and axis_name in source.slice_extents: + extent = source.slice_extents[axis_name] + bounds.extend([extent[0], extent[1]]) + else: + bounds.extend([0, 1]) + state.dataset_bounds = bounds - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update ClipSliceControl widget through context + if self.ctx.has("clip_slice"): + self.ctx.clip_slice.update_slice_values(source, source.slices) + # Update TimeNavigation widget through context + if self.ctx.has("time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/layouts.py b/src/pan3d/ui/layouts.py new file mode 100644 index 0000000..45f9115 --- /dev/null +++ b/src/pan3d/ui/layouts.py @@ -0,0 +1,97 @@ +"""Standard layout templates for Pan3D explorers.""" + +from pan3d.ui.control_panel import ControlPanel +from pan3d.widgets.error_alert import ErrorAlert +from pan3d.widgets.save_dataset_dialog import SaveDatasetDialog +from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.summary_toolbar import SummaryToolbar +from trame.ui.vuetify3 import VAppLayout +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class StandardExplorerLayout(VAppLayout): + """ + Standard layout template for Pan3D explorers. + + This class provides a consistent UI structure for all explorers: + - 3D view with VTK + - Scalar bar + - Save dialog + - Error alerts + - Time navigation + - Control panel with rendering settings + """ + + def __init__( + self, + explorer, + title="Explorer", + ): + """ + Create a standard explorer layout. + + Parameters: + explorer: The explorer instance (must have server, render_window, etc.) + title: Title for the explorer + view_class: Class to use for 3D view (default: Pan3DView) + view_kwargs: Additional kwargs for view class + rendering_settings_class: Class for rendering settings UI + rendering_settings_kwargs: Additional kwargs for rendering settings + additional_content: Function to add additional content (called with layout) + """ + super().__init__(explorer.server, fill_height=True) + + self.title = title + self.state.trame__title = self.title + + with self: + with v3.VMain(style="position: relative"): + with html.Div( + style="position: relative; width: 100%; height: 100%;", + ) as self.content: + # Scalar bar + ScalarBar( + ctx_name="scalar_bar", + v_show="!control_expended", + v_if="color_by", + ) + + # Save dialog + # Expect user to set the save_callback using context obj + SaveDatasetDialog( + ctx_name="save_dialog", + v_model=("show_save_dialog", False), + save_path_model=("save_dataset_path", "dataset.nc"), + title="Save dataset to disk", + ) + + # Error messages + ErrorAlert( + ctx_name="error_alert", + error_key="data_origin_error", + title="Error", + position="fixed", + location="bottom", + ) + + # Summary toolbar + SummaryToolbar( + v_show="!control_expended", + v_if="slice_t_max > 0", + ) + + # Control panel + self._control_panel = ControlPanel( + enable_data_selection=(explorer.xarray is None), + toggle="control_expended", + load_dataset=explorer.load_dataset, + import_file_upload=explorer.import_file_upload, + export_file_download=explorer.export_state, + xr_update_info="xr_update_info", + panel_label=self.title, + ) + + @property + def control_panel(self): + return self._control_panel.ui_content diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index 651a05e..c3a4121 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -1,9 +1,6 @@ -import math - -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.utils.convert import max_str_length -from trame.widgets import html +from pan3d.widgets import ClipSliceControl, TimeNavigation, VectorPropertyControl from trame.widgets import vuetify3 as v3 @@ -21,279 +18,59 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: v3.VDivider() - # X crop/cut - with v3.VTooltip( - v_if="axis_names?.[0]", - text=( - "`${axis_names[0]}: [${dataset_bounds[0]}, ${dataset_bounds[1]}] ${slice_x_type ==='range' ? ('(' + slice_x_range.map((v,i) => v+1).concat(slice_x_step).join(', ') + ')'): slice_x_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[0]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_x_type === 'range'", - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_range", None), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_cut", 0), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_x_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) - - # Y crop/cut - with v3.VTooltip( - v_if="axis_names?.[1]", - text=( - "`${axis_names[1]}: [${dataset_bounds[2]}, ${dataset_bounds[3]}] ${slice_y_type ==='range' ? ('(' + slice_y_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_y_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[1]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_y_type === 'range'", - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_range", None), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_cut", 0), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_y_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + # Clip/Slice controls for all axes + ClipSliceControl( + axis_names_var="axis_names", + dataset_bounds_var="dataset_bounds", + slice_extents_var="slice_extents", + type_vars=["slice_x_type", "slice_y_type", "slice_z_type"], + range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], + cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], + step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], + ctx_name="clip_slice", + ) - # Z crop/cut - with v3.VTooltip( - v_if="axis_names?.[2]", - text=( - "`${axis_names[2]}: [${dataset_bounds[4]}, ${dataset_bounds[5]}] ${slice_z_type ==='range' ? ('(' + slice_z_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_z_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_z_type === 'range'", - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_range", None), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_cut", 0), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_z_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) - v3.VDivider() + v3.VDivider() - # Slice steps - with v3.VTooltip(text="Level Of Details / Slice stepping"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-stairs", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model_number=("slice_x_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model_number=("slice_y_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model_number=("slice_z_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) + # Slice steps / Level of detail + VectorPropertyControl( + property_name="step", + icon="mdi-stairs", + tooltip="Level Of Details / Slice stepping", + x_name="slice_x_step", + y_name="slice_y_step", + z_name="slice_z_step", + axis_names_var="axis_names", + default_value=1, + min_value=1, + ) # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", + axis_names_var="axis_names", + default_value=1, + min_value=0.001, + max_value=100, + step=0.1, + classes="mx-2 my-2", + ) - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${t_labels[slice_t]} (${slice_t+1}/${slice_t_max+1})`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) + v3.VDivider() v3.VBtn( "Update 3D view", block=True, @@ -315,36 +92,23 @@ def update_from_source(self, source=None): # state.color_by = None state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents - slices = source.slices + + # Update dataset bounds for each axis + bounds = [] for axis in XYZ: - # default - axis_extent = state.slice_extents.get(getattr(source, axis)) - state[f"slice_{axis}_range"] = axis_extent - state[f"slice_{axis}_cut"] = 0 - state[f"slice_{axis}_step"] = 1 - state[f"slice_{axis}_type"] = "range" + axis_name = getattr(source, axis) + if axis_name and axis_name in source.slice_extents: + extent = source.slice_extents[axis_name] + bounds.extend([extent[0], extent[1]]) + else: + bounds.extend([0, 1]) + state.dataset_bounds = bounds - # use slice info if available - axis_slice = slices.get(getattr(source, axis)) - if axis_slice is not None: - if isinstance(axis_slice, int): - # cut - state[f"slice_{axis}_cut"] = axis_slice - state[f"slice_{axis}_type"] = "cut" - else: - # range - state[f"slice_{axis}_range"] = [ - axis_slice[0], - axis_slice[1] - 1, - ] # end is inclusive - state[f"slice_{axis}_step"] = axis_slice[2] + # Update ClipSliceControl widget through context + if self.ctx.has("clip_slice"): + self.ctx.clip_slice.update_slice_values(source, source.slices) - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if self.ctx.has("time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/rendering_settings.py b/src/pan3d/ui/rendering_settings.py new file mode 100644 index 0000000..4c36ef3 --- /dev/null +++ b/src/pan3d/ui/rendering_settings.py @@ -0,0 +1,98 @@ +"""Basic rendering settings UI component for Pan3D explorers.""" + +import numpy as np + +from pan3d.ui.collapsible import CollapsableSection +from pan3d.widgets.color_by import ColorBy +from trame.decorators import change +from trame.widgets import vuetify3 as v3 + + +class RenderingSettingsBasic(CollapsableSection): + """ + Basic rendering settings component that provides array selection + and color mapping controls. + + This component includes: + - Data array selection + - Color by variable selection + - Color preset and range controls + """ + + def __init__(self, source=None, update_rendering=None, **kwargs): + """ + Initialize the RenderingSettingsBasic component. + + Parameters: + source: VTK source object for data + update_rendering: Callback function to update rendering + **kwargs: Additional arguments passed to CollapsableSection + """ + super().__init__("Rendering", "show_rendering", **kwargs) + self.source = source + + with self.content: + v3.VSelect( + placeholder="Data arrays", + prepend_inner_icon="mdi-database", + hide_selected=True, + v_model=("data_arrays", []), + items=("data_arrays_available", []), + multiple=True, + hide_details=True, + density="compact", + chips=True, + closable_chips=True, + flat=True, + variant="solo", + ) + v3.VDivider() + self.color_by = ColorBy( + color_by_name="color_by", + preset_name="color_preset", + color_min_name="color_min", + color_max_name="color_max", + nan_color_name="nan_color", + reset_color_range=self.reset_color_range, + ) + + def reset_color_range(self): + """Reset the color range to the min and max values of the selected data array.""" + color_by = self.color_by.color_by + ds = self.source() + array = ( + ds.point_data[color_by] + if color_by in ds.point_data.keys() + else ds.cell_data[color_by] + if color_by in ds.cell_data.keys() + else None + ) + if array is not None: + self.color_by.color_min = float(np.min(array)) + self.color_by.color_max = float(np.max(array)) + else: + self.color_by.color_min = 0.0 + self.color_by.color_max = 1.0 + + self.ctrl.view_update() + + @change("data_arrays") + def _on_array_selection(self, data_arrays, **_): + # if self.state.import_pending: + # return + self.state.dirty_data = True + if self.source is not None: + self.source.arrays = data_arrays + + if self.source is None or self.source.input is None: + self.color_by.data_arrays = [] + else: + self.color_by.set_data_arrays_from_vtk(self.source()) + + def update_from_source(self, source=None): + raise NotImplementedError( + """ + This method needs to be implemented in the specialization of this class. + Please override it in the necessary class representing the rendering settings for the Explorer. + """ + ) diff --git a/src/pan3d/ui/slicer.py b/src/pan3d/ui/slicer.py index ac0dfe9..15261ef 100644 --- a/src/pan3d/ui/slicer.py +++ b/src/pan3d/ui/slicer.py @@ -1,7 +1,7 @@ -import math - -from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length +from pan3d.ui.rendering_settings import RenderingSettingsBasic +from pan3d.widgets.slice_control import SliceControl +from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -12,7 +12,6 @@ def __init__(self, source, update_rendering, **kwargs): self.source = source - style = {"density": "compact", "hide_details": True} with self.content: # -- Slice section v3.VDivider() @@ -34,127 +33,45 @@ def __init__(self, source, update_rendering, **kwargs): v_show="slice_axis === axis_names[2]", classes="text-subtitle-1", ) - with v3.VRow(classes="mx-2 my-0"): - v3.VSelect( - v_model=("slice_axis",), - items=("axis_names",), - **style, - ) - with v3.VRow(classes="mx-2 my-0"): - v3.VSlider( - v_show="slice_axis === axis_names[0]", - v_model=("cut_x",), - min=("bounds[0]",), - max=("bounds[1]",), - **style, - ) - v3.VSlider( - v_show="slice_axis === axis_names[1]", - v_model=("cut_y",), - min=("bounds[2]",), - max=("bounds[3]",), - **style, - ) - v3.VSlider( - v_show="slice_axis === axis_names[2]", - v_model=("cut_z",), - min=("bounds[4]",), - max=("bounds[5]",), - **style, - ) - with v3.VRow(classes="mx-2 my-0"): - with v3.VCol(): - html.Div( - "{{parseFloat(bounds[axis_names.indexOf(slice_axis)*2]).toFixed(2)}}", - classes="font-weight-medium", - ) - with v3.VCol(classes="text-right"): - html.Div( - "{{parseFloat(bounds[axis_names.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", - classes="font-weight-medium", - ) + # Use SliceControl widget for the rest + SliceControl( + slice_axis_var="slice_axis", + axis_names_var="axis_names", + cut_x_var="cut_x", + cut_y_var="cut_y", + cut_z_var="cut_z", + bounds_var="bounds", + show_value_display=False, # We already have custom display above + show_bounds=True, + ) v3.VDivider() # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", + axis_names_var="axis_names", + default_value=1, + min_value=0.001, + max_value=100, + step=0.1, + classes="mx-2 my-2", + ) v3.VDivider() - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${slice_t + 1} / ${slice_t_max+1}`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() v3.VBtn( "Update 3D view", @@ -189,16 +106,10 @@ def update_from_source(self, source=None): ] state.slice_extents = source.slice_extents - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if self.ctx.has("time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index # Update state from dataset state.bounds = ds.bounds diff --git a/src/pan3d/utils/common.py b/src/pan3d/utils/common.py index 5010b91..141eb7e 100644 --- a/src/pan3d/utils/common.py +++ b/src/pan3d/utils/common.py @@ -2,19 +2,12 @@ import traceback from pathlib import Path -import numpy as np - from pan3d import catalogs as pan3d_catalogs -from pan3d.ui.collapsible import CollapsableSection -from pan3d.ui.css import base, preview from pan3d.utils.constants import SLICE_VARS, XYZ from pan3d.utils.convert import update_camera -from pan3d.widgets.color_by import ColorBy from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.app import TrameApp, asynchronous from trame.decorators import change -from trame.widgets import html -from trame.widgets import vuetify3 as v3 class Explorer(TrameApp): @@ -90,6 +83,10 @@ def __init__( ] self.state.nan_color = 2 + # Initialize load button state + self.state.can_load = True + self.state.load_button_text = "Load" + self.ui = None # Initialize source @@ -138,6 +135,13 @@ def _process_cli(self, **_): self.state.show_data_information = True self.ctrl.xr_update_info(self.source.input, self.source.available_arrays) self.ctx.rendering.update_from_source(self.source) + # Initialize xarray tutorial datasets if no dataset is provided + elif self.state.data_origin_source == "xarray": + results, *_ = pan3d_catalogs.search("xarray") + self.state.data_origin_ids = [v["name"] for v in results] + self.state.data_origin_id_to_desc = { + v["name"]: v["description"] for v in results + } def start(self, **kwargs): """Initialize the UI and start the server for XArray Viewer.""" @@ -186,13 +190,20 @@ def _on_data_origin_id(self, data_origin_id, data_origin_source, **kwargs): # ----------------------------------------------------- @change("color_by", "color_preset", "color_min", "color_max", "nan_color") def _on_color_properties_change(self, **_): + if getattr(self, "_updating_color", False): + return # Prevent recursion + if self.mapper: - self.ctx.rendering.color_by.configure_mapper(self.mapper) - self.ctx.scalar_bar.preset = self.state.color_preset - self.ctx.scalar_bar.set_color_range( - self.state.color_min, self.state.color_max - ) - self.ctrl.view_update() + self._updating_color = True + try: + self.ctx.rendering.color_by.configure_mapper(self.mapper) + self.ctx.scalar_bar.preset = self.state.color_preset + self.ctx.scalar_bar.set_color_range( + self.state.color_min, self.state.color_max + ) + self.ctrl.view_update() + finally: + self._updating_color = False @change("slice_t", *[var.format(axis) for axis in XYZ for var in SLICE_VARS]) def on_change(self, slice_t, **_): @@ -418,435 +429,3 @@ def save_dataset(self, file_path): """ self.state.show_save_dialog = False return asynchronous.create_task(self._save_dataset(file_path)) - - -class SummaryToolbar(v3.VCard): - def __init__( - self, - t_labels="t_labels", - slice_t="slice_t", - slice_t_max="slice_t_max", - color_by="color_by", - data_arrays="data_arrays", - max_time_width="max_time_width", - max_time_index_width="max_time_index_width", - **kwargs, - ): - super().__init__( - classes="summary-toolbar", - rounded="pill", - **kwargs, - ) - - # Activate CSS - self.server.enable_module(base) - self.server.enable_module(preview) - - with self: - with v3.VToolbar( - classes="pl-2", - height=50, - elevation=1, - style="background: none;", - ): - v3.VIcon("mdi-clock-outline") - html.Pre( - f"{{{{ {t_labels}[slice_t] }}}}", - classes="mx-2 text-left", - style=(f"`min-width: ${{ {max_time_width} }}rem;`",), - ) - v3.VSlider( - prepend_inner_icon="mdi-clock-outline", - v_model=(slice_t, 0), - min=0, - max=(slice_t_max, 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - classes="mx-2", - ) - html.Div( - f"{{{{ {slice_t} + 1 }}}}/{{{{ {slice_t_max} + 1 }}}}", - classes="mx-2 text-right", - style=(f"`min-width: ${{ {max_time_index_width} }}rem;`",), - ) - v3.VSelect( - placeholder="Color By", - prepend_inner_icon="mdi-format-color-fill", - v_model=(color_by, None), - items=(data_arrays, []), - clearable=True, - hide_details=True, - density="compact", - flat=True, - variant="solo", - max_width=200, - ) - - -class DataOrigin(CollapsableSection): - def __init__(self, load_dataset): - super().__init__("Data origin", "show_data_origin", True) - - self.state.load_button_text = "Load" - self.state.can_load = True - self.state.data_origin_id_to_desc = {} - - with self.content: - v3.VSelect( - label="Source", - v_model=("data_origin_source", "xarray"), - items=( - "data_origin_sources", - pan3d_catalogs.list_availables(), - ), - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() - v3.VTextField( - placeholder="Location", - v_if="['file', 'url'].includes(data_origin_source)", - v_model=("data_origin_id", ""), - hide_details=True, - density="compact", - flat=True, - variant="solo", - append_inner_icon=( - "data_origin_id_error ? 'mdi-file-document-alert-outline' : undefined", - ), - error=("data_origin_id_error", False), - ) - - with v3.VTooltip( - v_else=True, - text=("`${ data_origin_id_to_desc[data_origin_id] }`",), - ): - with html.Template(v_slot_activator="{ props }"): - v3.VSelect( - v_bind="props", - label="Name", - v_model="data_origin_id", - items=("data_origin_ids", []), - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() - v3.VBtn( - "{{ load_button_text }}", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("!data_origin_id?.length || !can_load",), - color=("can_load ? 'primary': undefined",), - click=( - load_dataset, - "[data_origin_source, data_origin_id, data_origin_order]", - ), - ) - - -class DataInformation(CollapsableSection): - def __init__(self, xarray_info="xarray_info"): - super().__init__("Data information", "show_data_information") - - self._var_name = xarray_info - self.state.setdefault(xarray_info, []) - - with self.content: - with v3.VTable(density="compact", hover=True): - with html.Tbody(): - with html.Template(v_for=f"item, i in {xarray_info}", key="i"): - with v3.VTooltip(): - with html.Template(v_slot_activator="{ props }"): - with html.Tr(v_bind="props", classes="pointer"): - with html.Td( - classes="d-flex align-center text-no-wrap" - ): - v3.VIcon( - "{{ item.icon }}", - size="sm", - classes="mr-2", - ) - html.Div("{{ item.name }}") - html.Td( - "{{ item.length }}", - classes="text-right", - ) - - with v3.VTable( - density="compact", - theme="dark", - classes="no-bg ma-0 pa-0", - ): - with html.Tbody(): - with html.Tr( - v_for="attr, j in item.attrs", - key="j", - ): - html.Td( - "{{ attr.key }}", - ) - html.Td( - "{{ attr.value }}", - ) - - def update_information(self, xr, available_arrays=None): - xarray_info = [] - coords = set(xr.coords.keys()) - data = set(available_arrays or []) - for name in xr.variables: - icon = "mdi-variable" - order = 3 - length = f"({','.join(xr[name].dims)})" - attrs = [] - if name in coords: - icon = "mdi-ruler" - order = 1 - length = xr[name].size - shape = xr[name].shape - if length > 1 and len(shape) == 1: - attrs.append( - { - "key": "range", - "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", - } - ) - if name in data: - icon = "mdi-database" - order = 2 - xarray_info.append( - { - "order": order, - "icon": icon, - "name": name, - "length": length, - "type": str(xr[name].dtype), - "attrs": attrs - + [ - {"key": "type", "value": str(xr[name].dtype)}, - ] - + [ - {"key": str(k), "value": str(v)} - for k, v in xr[name].attrs.items() - ], - } - ) - xarray_info.sort(key=lambda item: item["order"]) - - # Update UI - self.state[self._var_name] = xarray_info - - -class ControlPanel(v3.VCard): - def __init__( - self, - enable_data_selection, - toggle, - load_dataset, - import_file_upload, - export_file_download, - xr_update_info="xr_update_info", - panel_label="XArray Viewer", - **kwargs, - ): - super().__init__( - classes="controller", - rounded=(f"{toggle} || 'circle'",), - **kwargs, - ) - - # state initialization - self.state.import_pending = False - - # extract trigger name - download_export = self.ctrl.trigger_name(export_file_download) - - with self: - with v3.VCardTitle( - classes=( - f"`d-flex pa-1 position-fixed bg-white ${{ {toggle} ? 'controller-content rounded-t border-b-thin':'rounded-circle'}}`", - ), - style="z-index: 1;", - ): - v3.VProgressLinear( - v_if=toggle, - indeterminate=("trame__busy",), - bg_color="rgba(0,0,0,0)", - absolute=True, - color="primary", - location="bottom", - height=2, - ) - v3.VProgressCircular( - v_else=True, - bg_color="rgba(0,0,0,0)", - indeterminate=("trame__busy",), - style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;", - color="primary", - width=3, - ) - v3.VBtn( - icon="mdi-close", - v_if=toggle, - click=f"{toggle} = !{toggle}", - flat=True, - size="sm", - ) - v3.VBtn( - icon="mdi-menu", - v_else=True, - click=f"{toggle} = !{toggle}", - flat=True, - size="sm", - ) - if self.server.hot_reload: - v3.VBtn( - v_show=toggle, - icon="mdi-refresh", - flat=True, - size="sm", - click=self.ctrl.on_server_reload, - ) - v3.VSpacer() - html.Div( - panel_label, - v_show=toggle, - classes="text-h6 px-2", - ) - v3.VSpacer() - - with v3.VMenu(v_if=toggle, density="compact"): - with html.Template(v_slot_activator="{props}"): - v3.VBtn( - v_bind="props", - icon="mdi-file-arrow-left-right-outline", - flat=True, - size="sm", - classes="mx-1", - ) - with v3.VList(density="compact"): - if enable_data_selection: - with v3.VListItem( - title="Export state file", - disabled=("can_load",), - click=f"utils.download('xarray-state.json', trigger('{download_export}'), 'text/plain')", - ): - with html.Template(v_slot_prepend=True): - v3.VIcon( - "mdi-cloud-download-outline", classes="mr-n5" - ) - - with v3.VListItem( - title="Import state file", - click="trame.utils.get('document').querySelector('#fileImport').click()", - ): - html.Input( - id="fileImport", - hidden=True, - type="file", - change=( - import_file_upload, - "[$event.target.files]", - ), - __events=["change"], - ) - with html.Template(v_slot_prepend=True): - v3.VIcon("mdi-cloud-upload-outline", classes="mr-n5") - v3.VDivider() - with v3.VListItem( - title="Save dataset to disk", - disabled=("can_load",), - click="show_save_dialog = true", - ): - with html.Template(v_slot_prepend=True): - v3.VIcon("mdi-file-download-outline", classes="mr-n5") - - with v3.VCardText( - v_show=(toggle, True), - classes="controller-content py-1 mt-10", - ) as ui_content: - self.ui_content = ui_content - if enable_data_selection: - DataOrigin(load_dataset) - self.ctrl[xr_update_info] = DataInformation().update_information - - -class RenderingSettingsBasic(CollapsableSection): - def __init__(self, source=None, update_rendering=None, **kwargs): - super().__init__("Rendering", "show_rendering", **kwargs) - self.source = source - - with self.content: - v3.VSelect( - placeholder="Data arrays", - prepend_inner_icon="mdi-database", - hide_selected=True, - v_model=("data_arrays", []), - items=("data_arrays_available", []), - multiple=True, - hide_details=True, - density="compact", - chips=True, - closable_chips=True, - flat=True, - variant="solo", - ) - v3.VDivider() - self.color_by = ColorBy( - color_by_name="color_by", - preset_name="color_preset", - color_min_name="color_min", - color_max_name="color_max", - nan_color_name="nan_color", - reset_color_range=self.reset_color_range, - ) - - def reset_color_range(self): - """Reset the color range to the min and max values of the selected data array.""" - color_by = self.color_by.color_by - ds = self.source() - array = ( - ds.point_data[color_by] - if color_by in ds.point_data.keys() - else ds.cell_data[color_by] - if color_by in ds.cell_data.keys() - else None - ) - if array is not None: - self.color_by.color_min = float(np.min(array)) - self.color_by.color_max = float(np.max(array)) - else: - self.color_by.color_min = 0.0 - self.color_by.color_max = 1.0 - - self.ctrl.view_update() - - @change("data_arrays") - def _on_array_selection(self, data_arrays, **_): - # if self.state.import_pending: - # return - self.state.dirty_data = True - if self.source is not None: - self.source.arrays = data_arrays - - if self.source is None or self.source.input is None: - self.color_by.data_arrays = [] - else: - self.color_by.set_data_arrays_from_vtk(self.source()) - - def update_from_source(self, source=None): - raise NotImplementedError( - """ - This method needs to be implemented in the specialization of this class. - Please override it in the necessary class representing the rendering settings for the Explorer. - """ - ) diff --git a/src/pan3d/viewers/preview.py b/src/pan3d/viewers/preview.py index fae5783..84418bc 100644 --- a/src/pan3d/viewers/preview.py +++ b/src/pan3d/viewers/preview.py @@ -13,15 +13,13 @@ vtkRenderWindowInteractor, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 class XArrayViewer(Explorer): @@ -78,78 +76,15 @@ def _setup_vtk(self, pipeline=None): def _build_ui(self, **kwargs): self.state.trame__title = "XArray Viewer" - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - ).ui_content: + # Use the standard UI creation method + with StandardExplorerLayout(explorer=self, title="XArray Viewer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widgets=[self.widget], + ) + with self.ui.control_panel: RenderingSettings( ctx_name="rendering", source=self.source, diff --git a/src/pan3d/widgets/__init__.py b/src/pan3d/widgets/__init__.py index e69de29..f57f747 100644 --- a/src/pan3d/widgets/__init__.py +++ b/src/pan3d/widgets/__init__.py @@ -0,0 +1,27 @@ +from .clip_slice_control import ClipSliceControl +from .color_by import ColorBy +from .data_information import DataInformation +from .data_origin import DataOrigin +from .error_alert import ErrorAlert +from .pan3d_view import Pan3DView +from .save_dataset_dialog import SaveDatasetDialog +from .scalar_bar import ScalarBar +from .slice_control import SliceControl +from .summary_toolbar import SummaryToolbar +from .time_navigation import TimeNavigation +from .vector_property_control import VectorPropertyControl + +__all__ = [ + "ClipSliceControl", + "ColorBy", + "DataInformation", + "DataOrigin", + "ErrorAlert", + "Pan3DView", + "SaveDatasetDialog", + "ScalarBar", + "SliceControl", + "SummaryToolbar", + "TimeNavigation", + "VectorPropertyControl", +] diff --git a/src/pan3d/widgets/clip_slice_control.py b/src/pan3d/widgets/clip_slice_control.py new file mode 100644 index 0000000..b16937c --- /dev/null +++ b/src/pan3d/widgets/clip_slice_control.py @@ -0,0 +1,305 @@ +"""Clip/Slice Control widget for multi-axis data clipping and slicing.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ClipSliceControl(html.Div): + """ + A widget for controlling clipping/cropping along all axes (X, Y, Z). + + Provides range selection or single cut value for data clipping on each axis. + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + state_prefix=None, + axis_names_var=None, + dataset_bounds_var=None, + slice_extents_var=None, + # Optional lists/tuples for custom axis variable names [x, y, z] + type_vars=None, # e.g., ["slice_x_type", "slice_y_type", "slice_z_type"] + range_vars=None, # e.g., ["slice_x_range", "slice_y_range", "slice_z_range"] + cut_vars=None, # e.g., ["slice_x_cut", "slice_y_cut", "slice_z_cut"] + step_vars=None, # e.g., ["slice_x_step", "slice_y_step", "slice_z_step"] + ctx_name=None, # Context name for programmatic access + **kwargs, + ): + """ + Initialize the ClipSliceControl widget. + + Args: + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + axis_names_var: State variable for axis names array + dataset_bounds_var: State variable for dataset bounds + slice_extents_var: State variable for slice extents + type_vars: Optional list/tuple of variable names for axis types [x, y, z] + range_vars: Optional list/tuple of variable names for axis ranges [x, y, z] + cut_vars: Optional list/tuple of variable names for axis cut values [x, y, z] + step_vars: Optional list/tuple of variable names for axis steps [x, y, z] + ctx_name: Context name for programmatic access (e.g., "clip_slice") + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + ClipSliceControl._next_id += 1 + self._id = ClipSliceControl._next_id + ns = state_prefix or f"clip_slice_{self._id}" + + # Initialize shared state variables + self.__axis_names = axis_names_var or f"{ns}_axis_names" + self.__dataset_bounds = dataset_bounds_var or f"{ns}_dataset_bounds" + self.__slice_extents = slice_extents_var or f"{ns}_slice_extents" + + # Helper function to get variable name from list or use default + def get_var(var_list, index, axis, suffix): + if var_list and len(var_list) > index: + return var_list[index] + return f"{ns}_slice_{axis}_{suffix}" + + # Initialize per-axis state variables as private instance variables + # X axis + self.__x_type = get_var(type_vars, 0, "x", "type") + self.__x_range = get_var(range_vars, 0, "x", "range") + self.__x_cut = get_var(cut_vars, 0, "x", "cut") + self.__x_step = get_var(step_vars, 0, "x", "step") + + # Y axis + self.__y_type = get_var(type_vars, 1, "y", "type") + self.__y_range = get_var(range_vars, 1, "y", "range") + self.__y_cut = get_var(cut_vars, 1, "y", "cut") + self.__y_step = get_var(step_vars, 1, "y", "step") + + # Z axis + self.__z_type = get_var(type_vars, 2, "z", "type") + self.__z_range = get_var(range_vars, 2, "z", "range") + self.__z_cut = get_var(cut_vars, 2, "z", "cut") + self.__z_step = get_var(step_vars, 2, "z", "step") + + # Build UI + with self: + # X crop/cut + with v3.VTooltip( + v_if=f"{self.__axis_names}?.[0]", + text=( + f"`${{{self.__axis_names}[0]}}: [${{{self.__dataset_bounds}[0]}}, ${{{self.__dataset_bounds}[1]}}] " + f"${{{self.__x_type} ==='range' ? " + f"('(' + {self.__x_range}.map((v,i) => v+1).concat({self.__x_step}).join(', ') + ')') : " + f"{self.__x_cut}}}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=f"{self.__axis_names}?.[0]", + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{self.__x_type} === 'range'", + prepend_icon="mdi-axis-x-arrow", + v_model=(self.__x_range, None), + min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-x-arrow", + v_model=(self.__x_cut, 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(self.__x_type, "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Y crop/cut + with v3.VTooltip( + v_if=f"{self.__axis_names}?.[1]", + text=( + f"`${{{self.__axis_names}[1]}}: [${{{self.__dataset_bounds}[2]}}, ${{{self.__dataset_bounds}[3]}}] " + f"${{{self.__y_type} ==='range' ? " + f"('(' + {self.__y_range}.map((v,i) => v+1).concat({self.__y_step}).join(', ') + ')') : " + f"{self.__y_cut}}}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=f"{self.__axis_names}?.[1]", + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{self.__y_type} === 'range'", + prepend_icon="mdi-axis-y-arrow", + v_model=(self.__y_range, None), + min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-y-arrow", + v_model=(self.__y_cut, 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(self.__y_type, "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Z crop/cut + with v3.VTooltip( + v_if=f"{self.__axis_names}?.[2]", + text=( + f"`${{{self.__axis_names}[2]}}: [${{{self.__dataset_bounds}[4]}}, ${{{self.__dataset_bounds}[5]}}] " + f"${{{self.__z_type} ==='range' ? " + f"('(' + {self.__z_range}.map((v,i) => v+1).concat({self.__z_step}).join(', ') + ')') : " + f"{self.__z_cut}}}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_bind="props", + v_if=f"{self.__axis_names}?.[2]", + ): + v3.VRangeSlider( + v_if=f"{self.__z_type} === 'range'", + prepend_icon="mdi-axis-z-arrow", + v_model=(self.__z_range, None), + min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-z-arrow", + v_model=(self.__z_cut, 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(self.__z_type, "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Register in context if ctx_name provided + if ctx_name and hasattr(self, "ctx"): + self.ctx[ctx_name] = self + + def set_axis_type(self, axis, type_value): + """Set the type (range or cut) for a specific axis.""" + if axis == "x": + self.state[self.__x_type] = type_value + elif axis == "y": + self.state[self.__y_type] = type_value + elif axis == "z": + self.state[self.__z_type] = type_value + + def set_axis_range(self, axis, range_value): + """Set the range value for a specific axis.""" + if axis == "x": + self.state[self.__x_range] = range_value + elif axis == "y": + self.state[self.__y_range] = range_value + elif axis == "z": + self.state[self.__z_range] = range_value + + def set_axis_cut(self, axis, cut_value): + """Set the cut value for a specific axis.""" + if axis == "x": + self.state[self.__x_cut] = cut_value + elif axis == "y": + self.state[self.__y_cut] = cut_value + elif axis == "z": + self.state[self.__z_cut] = cut_value + + def set_axis_step(self, axis, step_value): + """Set the step value for a specific axis.""" + if axis == "x": + self.state[self.__x_step] = step_value + elif axis == "y": + self.state[self.__y_step] = step_value + elif axis == "z": + self.state[self.__z_step] = step_value + + def update_slice_values(self, source, slices): + """Update all slice values from source configuration.""" + from pan3d.utils.constants import XYZ + + for axis in XYZ: + # default values + axis_extent = self.state[self.__slice_extents].get(getattr(source, axis)) + self.set_axis_range(axis, axis_extent) + self.set_axis_cut(axis, 0) + self.set_axis_step(axis, 1) + self.set_axis_type(axis, "range") + + # use slice info if available + axis_slice = slices.get(getattr(source, axis)) + if axis_slice is not None: + if isinstance(axis_slice, int): + # cut + self.set_axis_cut(axis, axis_slice) + self.set_axis_type(axis, "cut") + else: + # range + self.set_axis_range( + axis, [axis_slice[0], axis_slice[1] - 1] + ) # end is inclusive + self.set_axis_step(axis, axis_slice[2]) diff --git a/src/pan3d/widgets/color_by.py b/src/pan3d/widgets/color_by.py index 87604d7..b45a4f2 100644 --- a/src/pan3d/widgets/color_by.py +++ b/src/pan3d/widgets/color_by.py @@ -315,8 +315,7 @@ def color_min(self): @color_min.setter def color_min(self, value): """Set the minimum value of the color mapping range.""" - with self.state: - self.state[self.__color_min] = value + self.state[self.__color_min] = value @property def color_min_name(self): @@ -329,8 +328,7 @@ def color_max(self): @color_max.setter def color_max(self, value): """Set the maximum value of the color mapping range.""" - with self.state: - self.state[self.__color_max] = value + self.state[self.__color_max] = value @property def color_max_name(self): diff --git a/src/pan3d/widgets/data_information.py b/src/pan3d/widgets/data_information.py new file mode 100644 index 0000000..3172263 --- /dev/null +++ b/src/pan3d/widgets/data_information.py @@ -0,0 +1,104 @@ +"""Data Information widget for displaying dataset metadata.""" + +from pan3d.ui.collapsible import CollapsableSection +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class DataInformation(CollapsableSection): + """ + Data information display widget that matches the original behavior. + + Extends CollapsableSection to provide a collapsible panel for dataset information. + """ + + def __init__(self, xarray_info="xarray_info"): + """Initialize the DataInformation widget.""" + super().__init__("Data information", "show_data_information") + + self._var_name = xarray_info + self.state.setdefault(xarray_info, []) + + with self.content: + with v3.VTable(density="compact", hover=True): + with html.Tbody(): + with html.Template(v_for=f"item, i in {xarray_info}", key="i"): + with v3.VTooltip(): + with html.Template(v_slot_activator="{ props }"): + with html.Tr(v_bind="props", classes="pointer"): + with html.Td( + classes="d-flex align-center text-no-wrap" + ): + v3.VIcon( + "{{ item.icon }}", + size="sm", + classes="mr-2", + ) + html.Div("{{ item.name }}") + html.Td( + "{{ item.length }}", + classes="text-right", + ) + + with v3.VTable( + density="compact", + theme="dark", + classes="no-bg ma-0 pa-0", + ): + with html.Tbody(): + with html.Tr( + v_for="attr, j in item.attrs", + key="j", + ): + html.Td( + "{{ attr.key }}", + ) + html.Td( + "{{ attr.value }}", + ) + + def update_information(self, xr, available_arrays=None): + xarray_info = [] + coords = set(xr.coords.keys()) + data = set(available_arrays or []) + for name in xr.variables: + icon = "mdi-variable" + order = 3 + length = f"({','.join(xr[name].dims)})" + attrs = [] + if name in coords: + icon = "mdi-ruler" + order = 1 + length = xr[name].size + shape = xr[name].shape + if length > 1 and len(shape) == 1: + attrs.append( + { + "key": "range", + "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", + } + ) + if name in data: + icon = "mdi-database" + order = 2 + xarray_info.append( + { + "order": order, + "icon": icon, + "name": name, + "length": length, + "type": str(xr[name].dtype), + "attrs": attrs + + [ + {"key": "type", "value": str(xr[name].dtype)}, + ] + + [ + {"key": str(k), "value": str(v)} + for k, v in xr[name].attrs.items() + ], + } + ) + xarray_info.sort(key=lambda item: item["order"]) + + # Update UI + self.state[self._var_name] = xarray_info diff --git a/src/pan3d/widgets/data_origin.py b/src/pan3d/widgets/data_origin.py new file mode 100644 index 0000000..611ec5b --- /dev/null +++ b/src/pan3d/widgets/data_origin.py @@ -0,0 +1,90 @@ +"""Data Origin widget for selecting and loading data from various sources.""" + +from pan3d import catalogs as pan3d_catalogs +from pan3d.ui.collapsible import CollapsableSection +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class DataOrigin(CollapsableSection): + """ + Data origin selection widget that matches the original behavior. + + Extends CollapsableSection to provide a collapsible panel for data source selection. + + Note: This widget uses fixed state variable names (data_origin_source, data_origin_id, etc.) + instead of the namespace pattern because these variables are accessed across multiple + components in the application. + """ + + def __init__(self, load_dataset): + """ + Initialize the DataOrigin widget. + + Args: + load_dataset: Callback function to load the dataset + """ + super().__init__("Data origin", "show_data_origin", True) + + self.state.load_button_text = "Load" + self.state.can_load = True + self.state.data_origin_id_to_desc = {} + + with self.content: + v3.VSelect( + label="Source", + v_model=("data_origin_source", "xarray"), + items=( + "data_origin_sources", + pan3d_catalogs.list_availables(), + ), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VTextField( + placeholder="Location", + v_if="['file', 'url'].includes(data_origin_source)", + v_model=("data_origin_id", ""), + hide_details=True, + density="compact", + flat=True, + variant="solo", + append_inner_icon=( + "data_origin_id_error ? 'mdi-file-document-alert-outline' : undefined", + ), + error=("data_origin_id_error", False), + ) + + with v3.VTooltip( + v_else=True, + text=("`${ data_origin_id_to_desc[data_origin_id] }`",), + ): + with html.Template(v_slot_activator="{ props }"): + v3.VSelect( + v_bind="props", + label="Name", + v_model="data_origin_id", + items=("data_origin_ids", []), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VBtn( + "{{ load_button_text }}", + block=True, + classes="text-none", + flat=True, + density="compact", + rounded=0, + disabled=("!data_origin_id?.length || !can_load",), + color=("can_load ? 'primary': undefined",), + click=( + load_dataset, + "[data_origin_source, data_origin_id, data_origin_order]", + ), + ) diff --git a/src/pan3d/widgets/error_alert.py b/src/pan3d/widgets/error_alert.py new file mode 100644 index 0000000..4a5cee1 --- /dev/null +++ b/src/pan3d/widgets/error_alert.py @@ -0,0 +1,51 @@ +"""Error Alert widget for consistent error display across explorers.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ErrorAlert(v3.VAlert): + """ + A reusable error alert component. + + Provides consistent error display across all explorers. + """ + + def __init__( + self, + error_key="data_origin_error", + title="Error", + position="fixed", + location="bottom", + max_width=650, + type="error", + **kwargs, + ): + """ + Initialize the ErrorAlert widget. + + Args: + error_key: State key for error message + title: Alert title + position: CSS position (fixed, absolute, etc.) + location: Vuetify location (bottom, top, etc.) + max_width: Maximum width of alert + type: Alert type (error, warning, info, success) + **kwargs: Additional VAlert properties + """ + super().__init__( + v_model=(error_key, False), + position=position, + location=location, + max_width=max_width, + type=type, + closable=True, + border="start", + **kwargs, + ) + + with self: + with v3.VAlertTitle(): + v3.VIcon("mdi-alert-circle", classes="mr-2") + html.Span(title, classes="text-h6") + html.Div(f"{{{{ {error_key} }}}}", classes="text-body-2 mt-2") diff --git a/src/pan3d/ui/vtk_view.py b/src/pan3d/widgets/pan3d_view.py similarity index 99% rename from src/pan3d/ui/vtk_view.py rename to src/pan3d/widgets/pan3d_view.py index b0cbcf7..28a415c 100644 --- a/src/pan3d/ui/vtk_view.py +++ b/src/pan3d/widgets/pan3d_view.py @@ -1,13 +1,12 @@ from pan3d.ui.css import base, vtk_view from pan3d.utils.constants import VIEW_UPS -from trame.decorators import TrameApp, change +from trame.decorators import change from trame.widgets import html from trame.widgets import vtk as vtkw from trame.widgets import vtklocal as wasm from trame.widgets import vuetify3 as v3 -@TrameApp() class Pan3DView(html.Div): def __init__( self, diff --git a/src/pan3d/widgets/save_dataset_dialog.py b/src/pan3d/widgets/save_dataset_dialog.py new file mode 100644 index 0000000..061891e --- /dev/null +++ b/src/pan3d/widgets/save_dataset_dialog.py @@ -0,0 +1,70 @@ +"""Save Dataset Dialog widget for consistent save functionality across explorers.""" + +from trame.widgets import vuetify3 as v3 + + +class SaveDatasetDialog(v3.VDialog): + """ + A reusable dialog for saving datasets. + + Used across all explorers to provide consistent save functionality. + """ + + def __init__( + self, + save_callback=None, + v_model=("show_save_dialog", False), + save_path_model=("save_path", "output.nc"), + title="Save Dataset", + width=500, + **kwargs, + ): + """ + Initialize the SaveDatasetDialog widget. + + Args: + save_callback: Function to call when save is confirmed + v_model: State binding for dialog visibility + save_path_model: State binding for save path + title: Dialog title + width: Dialog width + **kwargs: Additional VDialog properties + """ + super().__init__(v_model=v_model, width=width, **kwargs) + + with self: + with v3.VCard(): + v3.VCardTitle(title) + with v3.VCardText(): + v3.VTextField( + v_model=save_path_model, + label="File Path", + hint="Specify the path where the dataset will be saved", + persistent_hint=True, + prepend_inner_icon="mdi-file", + variant="outlined", + density="compact", + ) + with v3.VCardActions(): + v3.VSpacer() + v3.VBtn( + "Cancel", + click=f"{v_model[0]} = false", + text=True, + ) + v3.VBtn( + "Save", + click=(self.save_callback, f"[{save_path_model[0]}]"), + variant="elevated", + color="primary", + ) + + @property + def save_callback(self): + """Get the save callback function.""" + return self._save_callback + + @save_callback.setter + def save_callback(self, value): + """Set the save callback function.""" + self._save_callback = value diff --git a/src/pan3d/widgets/slice_control.py b/src/pan3d/widgets/slice_control.py new file mode 100644 index 0000000..7ee032c --- /dev/null +++ b/src/pan3d/widgets/slice_control.py @@ -0,0 +1,135 @@ +"""Slice Control widget for single-axis slicing operations.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class SliceControl(html.Div): + """ + A widget for controlling slicing along selectable axes. + + Provides axis selection and position control for slice exploration. + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + state_prefix=None, + slice_axis_var=None, + axis_names_var=None, + cut_x_var=None, + cut_y_var=None, + cut_z_var=None, + bounds_var=None, + # UI options + show_value_display=True, + show_bounds=True, + **kwargs, + ): + """ + Initialize the SliceControl widget. + + Args: + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + slice_axis_var: State variable for selected axis (default: {prefix}_slice_axis) + axis_names_var: State variable for axis names array (default: {prefix}_axis_names) + cut_x_var: State variable for X cut position (default: {prefix}_cut_x) + cut_y_var: State variable for Y cut position (default: {prefix}_cut_y) + cut_z_var: State variable for Z cut position (default: {prefix}_cut_z) + bounds_var: State variable for bounds array (default: {prefix}_bounds) + show_value_display: Whether to show current value + show_bounds: Whether to show min/max bounds + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + SliceControl._next_id += 1 + self._id = SliceControl._next_id + ns = state_prefix or f"slice_control_{self._id}" + + # Initialize state variable names as private instance variables + self.__slice_axis = slice_axis_var or f"{ns}_slice_axis" + self.__axis_names = axis_names_var or f"{ns}_axis_names" + self.__cut_x = cut_x_var or f"{ns}_cut_x" + self.__cut_y = cut_y_var or f"{ns}_cut_y" + self.__cut_z = cut_z_var or f"{ns}_cut_z" + self.__bounds = bounds_var or f"{ns}_bounds" + + # Note: State initialization must be done by the parent explorer/viewer + # Widgets don't have access to state during construction + + style = { + "hide_details": True, + "density": "compact", + "flat": True, + "variant": "solo", + } + + with self: + # Value display + if show_value_display: + with html.Div(classes="d-flex align-center justify-center mb-2"): + html.Span( + f"{{{{parseFloat({self.__cut_x}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[0]", + classes="text-subtitle-1", + ) + html.Span( + f"{{{{parseFloat({self.__cut_y}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[1]", + classes="text-subtitle-1", + ) + html.Span( + f"{{{{parseFloat({self.__cut_z}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[2]", + classes="text-subtitle-1", + ) + + # Axis selector + with v3.VRow(classes="mx-2 my-0"): + v3.VSelect( + v_model=(self.__slice_axis,), + items=(self.__axis_names,), + **style, + ) + + # Sliders for each axis + with v3.VRow(classes="mx-2 my-0"): + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[0]", + v_model=(self.__cut_x,), + min=(f"{self.__bounds}[0]",), + max=(f"{self.__bounds}[1]",), + **style, + ) + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[1]", + v_model=(self.__cut_y,), + min=(f"{self.__bounds}[2]",), + max=(f"{self.__bounds}[3]",), + **style, + ) + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[2]", + v_model=(self.__cut_z,), + min=(f"{self.__bounds}[4]",), + max=(f"{self.__bounds}[5]",), + **style, + ) + + # Bounds display + if show_bounds: + with v3.VRow(classes="mx-2 my-0"): + with v3.VCol(): + html.Div( + f"{{{{parseFloat({self.__bounds}[{self.__axis_names}.indexOf({self.__slice_axis})*2]).toFixed(2)}}}}", + classes="font-weight-medium", + ) + with v3.VCol(classes="text-right"): + html.Div( + f"{{{{parseFloat({self.__bounds}[{self.__axis_names}.indexOf({self.__slice_axis})*2 + 1]).toFixed(2)}}}}", + classes="font-weight-medium", + ) diff --git a/src/pan3d/widgets/summary_toolbar.py b/src/pan3d/widgets/summary_toolbar.py new file mode 100644 index 0000000..dc89223 --- /dev/null +++ b/src/pan3d/widgets/summary_toolbar.py @@ -0,0 +1,95 @@ +"""Summary toolbar widget for displaying time navigation and color selection.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class SummaryToolbar(v3.VCard): + """ + A toolbar widget that displays time navigation controls and color selection. + + This widget provides a compact interface for: + - Displaying current time label + - Time slider navigation + - Time index display + - Color by variable selection + """ + + def __init__( + self, + t_labels="t_labels", + slice_t="slice_t", + slice_t_max="slice_t_max", + color_by="color_by", + data_arrays="data_arrays", + max_time_width="max_time_width", + max_time_index_width="max_time_index_width", + **kwargs, + ): + """ + Initialize the SummaryToolbar widget. + + Parameters: + t_labels: State variable name for time labels list + slice_t: State variable name for current time index + slice_t_max: State variable name for maximum time index + color_by: State variable name for color by selection + data_arrays: State variable name for available data arrays + max_time_width: State variable name for max time label width + max_time_index_width: State variable name for max time index width + **kwargs: Additional arguments passed to VCard + """ + super().__init__( + classes="summary-toolbar", + rounded="pill", + **kwargs, + ) + + # Activate CSS - these modules should be imported and enabled at app level + from pan3d.ui.css import base, preview + + self.server.enable_module(base) + self.server.enable_module(preview) + + with self: + with v3.VToolbar( + classes="pl-2", + height=50, + elevation=1, + style="background: none;", + ): + v3.VIcon("mdi-clock-outline") + html.Pre( + f"{{{{ {t_labels}[slice_t] }}}}", + classes="mx-2 text-left", + style=(f"`min-width: ${{ {max_time_width} }}rem;`",), + ) + v3.VSlider( + prepend_inner_icon="mdi-clock-outline", + v_model=(slice_t, 0), + min=0, + max=(slice_t_max, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + classes="mx-2", + ) + html.Div( + f"{{{{ {slice_t} + 1 }}}}/{{{{ {slice_t_max} + 1 }}}}", + classes="mx-2 text-right", + style=(f"`min-width: ${{ {max_time_index_width} }}rem;`",), + ) + v3.VSelect( + placeholder="Color By", + prepend_inner_icon="mdi-format-color-fill", + v_model=(color_by, None), + items=(data_arrays, []), + clearable=True, + hide_details=True, + density="compact", + flat=True, + variant="solo", + max_width=200, + ) diff --git a/src/pan3d/widgets/time_navigation.py b/src/pan3d/widgets/time_navigation.py new file mode 100644 index 0000000..8e3b39f --- /dev/null +++ b/src/pan3d/widgets/time_navigation.py @@ -0,0 +1,134 @@ +"""Time Navigation widget for temporal data exploration.""" + +import math + +from pan3d.ui.css import base, preview +from pan3d.utils.convert import max_str_length +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class TimeNavigation(html.Div): + """ + Presentation widget for navigating through time-based data. + + Provides: + - Time slider with current position + - Time labels display with tooltip + - Index display (current/total) + + Usage: + time_nav = TimeNavigation( + labels=["2020-01-01", "2020-01-02", ...], + index_name="slice_t", + labels_name="t_labels" + ) + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + index_name=None, + labels_name=None, + labels=None, + **kwargs, + ): + """ + Create a time navigation widget. + + Parameters + ---------- + index_name : str, optional + State variable name for current index + labels_name : str, optional + State variable name for labels array + labels : list, optional + Initial list of time labels (strings) + """ + super().__init__(**kwargs) + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(preview) + + # Generate unique namespace + TimeNavigation._next_id += 1 + self._id = TimeNavigation._next_id + ns = f"time_nav_{self._id}" + + # Initialize state variables + self.__index = index_name or f"{ns}_index" + self.__labels = labels_name or f"{ns}_labels" + self.__max_index = f"{ns}_max_index" + + # Set default state + self.state[self.__index] = 0 + self.state[self.__labels] = labels if labels is not None else [] + self.state[self.__max_index] = len(labels) - 1 if labels else 0 + + # Build UI directly in __init__ + with self: + with v3.VTooltip( + v_if=f"{self.__max_index} > 0", + text=( + f"`time: ${{{self.__labels}[{self.__index}]}} (${{{self.__index}+1}}/${{{self.__max_index}+1}})`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex pr-2", + v_bind="props", + ): + v3.VSlider( + prepend_icon="mdi-clock-outline", + v_model=(self.__index, 0), + min=0, + max=(self.__max_index, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + + @property + def index(self): + """Get the current time index.""" + return self.state[self.__index] + + @index.setter + def index(self, value): + """Set the current time index.""" + with self.state: + # Ensure index is within valid range + max_index = self.state[self.__max_index] + self.state[self.__index] = max(0, min(int(value), max_index)) + + @property + def labels(self): + """Get the time labels.""" + return self.state[self.__labels] + + @labels.setter + def labels(self, value): + """Set the time labels and update max index.""" + with self.state: + self.state[self.__labels] = list(value) if value else [] + max_index = len(value) - 1 if value else 0 + self.state[self.__max_index] = max_index + + # Also set slice_t_max for backward compatibility + self.state.slice_t_max = max_index + + # Calculate presentation-specific widths + self.state.max_time_width = ( + math.ceil(0.58 * max_str_length(value)) if value else 0 + ) + if max_index > 0: + self.state.max_time_index_width = math.ceil( + 0.6 + (math.log10(max_index + 1) + 1) * 2 * 0.58 + ) + else: + self.state.max_time_index_width = 0 diff --git a/src/pan3d/widgets/vector_property_control.py b/src/pan3d/widgets/vector_property_control.py new file mode 100644 index 0000000..96db712 --- /dev/null +++ b/src/pan3d/widgets/vector_property_control.py @@ -0,0 +1,147 @@ +"""Generic Vector Property Control widget for X, Y, Z properties.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class VectorPropertyControl(html.Div): + """ + A generic widget for controlling X, Y, Z vector properties. + + Can be used for scale, step, position, or any other 3D vector property. + """ + + _next_id = 0 + + def __init__( + self, + # Property configuration + property_name="property", + icon="mdi-axis-arrow", + tooltip="Vector property", + # State variable names (optional) + state_prefix=None, + x_name=None, + y_name=None, + z_name=None, + axis_names_var=None, + # Value configuration + default_value=1, + min_value=None, + max_value=None, + step=None, + use_number_model=True, # Default to v_model_number for numeric values + **kwargs, + ): + """ + Initialize the VectorPropertyControl widget. + + Args: + property_name: Base name for the property (e.g., "scale", "step") + icon: Material Design Icon name to display + tooltip: Tooltip text to show on hover + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + x_name: State variable name for X component (default: {prefix}_{property_name}_x) + y_name: State variable name for Y component (default: {prefix}_{property_name}_y) + z_name: State variable name for Z component (default: {prefix}_{property_name}_z) + axis_names_var: State variable name for axis names array (default: {prefix}_axis_names) + default_value: Default value for all components + min_value: Minimum allowed value + max_value: Maximum allowed value + step: Step increment for number inputs + use_number_model: If True, uses v_model_number; if False, uses v_model (default: True) + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + VectorPropertyControl._next_id += 1 + self._id = VectorPropertyControl._next_id + ns = state_prefix or f"{property_name}_control_{self._id}" + + # Initialize state variable names as private instance variables + self.__x = x_name or f"{ns}_{property_name}_x" + self.__y = y_name or f"{ns}_{property_name}_y" + self.__z = z_name or f"{ns}_{property_name}_z" + self.__axis_names = axis_names_var or f"{ns}_axis_names" + + # Build raw attributes for input validation + raw_attrs = [] + if min_value is not None: + raw_attrs.append(f'min="{min_value}"') + if max_value is not None: + raw_attrs.append(f'max="{max_value}"') + if step is not None: + raw_attrs.append(f'step="{step}"') + + # Determine which v_model to use + v_model_attr = "v_model_number" if use_number_model else "v_model" + + with self: + with v3.VTooltip(text=tooltip): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutters=bool(use_number_model), + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + icon, + classes="ml-2 text-medium-emphasis", + ) + # X component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[0]"): + v3.VTextField( + **{v_model_attr: (self.__x, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + # Y component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[1]"): + v3.VTextField( + **{v_model_attr: (self.__y, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + # Z component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[2]"): + v3.VTextField( + **{v_model_attr: (self.__z, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + + @property + def x_var(self): + """Get the X component state variable name.""" + return self.__x + + @property + def y_var(self): + """Get the Y component state variable name.""" + return self.__y + + @property + def z_var(self): + """Get the Z component state variable name.""" + return self.__z + + @property + def axis_names_var(self): + """Get the axis names state variable name.""" + return self.__axis_names diff --git a/src/trame/widgets/pan3d.py b/src/trame/widgets/pan3d.py index 09deb22..f01c264 100644 --- a/src/trame/widgets/pan3d.py +++ b/src/trame/widgets/pan3d.py @@ -1,4 +1,17 @@ +from pan3d.widgets.clip_slice_control import ClipSliceControl from pan3d.widgets.color_by import ColorBy +from pan3d.widgets.data_information import DataInformation from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.slice_control import SliceControl +from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl -__all__ = ["ColorBy", "ScalarBar"] +__all__ = [ + "ClipSliceControl", + "ColorBy", + "DataInformation", + "ScalarBar", + "SliceControl", + "TimeNavigation", + "VectorPropertyControl", +]