From 801735f3ef4296ffc292f1c3c5ba2ac7ec380b94 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 26 Apr 2024 19:11:23 +0000
Subject: [PATCH 01/23] fix: slice by index instead of value to allow slicing
 time coord

---
 pan3d/dataset_builder.py | 31 ++++++++++++++++++++++++++++---
 1 file changed, 28 insertions(+), 3 deletions(-)

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index a7e459d8..7bdb1b62 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -1,5 +1,6 @@
 import os
 import json
+import pandas
 import pyvista
 import xarray
 
@@ -335,17 +336,41 @@ def slicing(self, slicing: Dict[str, List]) -> None:
                     raise ValueError(
                         "Values in slicing must be lists of length 3 ([start, stop, step])."
                     )
-                acceptable_coords = self.dataset[self.data_array_name].coords
+                da = self.dataset[self.data_array_name]
+                acceptable_coords = da.dims
                 if key not in acceptable_coords:
                     raise ValueError(
                         f"Key {key} not found in data array. Must be one of {list(acceptable_coords.keys())}."
                     )
-                key_coord = acceptable_coords[key]
+                key_coord = da[key]
 
-                if value[2] > key_coord.size:
+                step = value[2]
+                if step > key_coord.size:
                     raise ValueError(
                         f"Value {value} not applicable for Key {key}. Step value must be <= {key_coord.size}."
                     )
+
+                start_value = value[0]
+                end_value = value[1]
+                if key_coord.dtype.kind in ['O', 'M']:
+                    start_value = pandas.to_datetime(start_value)
+                    end_value = pandas.to_datetime(end_value)
+                elif key_coord.dtype.kind in ['m']:
+                    start_value = pandas.to_timedelta(start_value).total_seconds()
+                    end_value = pandas.to_timedelta(end_value).total_seconds()
+                start_index = None
+                end_index = None
+                for i, c in enumerate(key_coord.to_series()):
+                    if key_coord.dtype.kind in ['O', 'M']:
+                        c = pandas.to_datetime(str(c))
+                    elif key_coord.dtype.kind in ['m']:
+                        c = pandas.to_timedelta(c).total_seconds()
+                    if c >= start_value and start_index is None:
+                        start_index = i
+                    if c <= end_value:
+                        end_index = i
+                slicing[key] = [start_index, end_index, step]
+
         self._algorithm.slicing = slicing
         if self._viewer:
             self._viewer._data_slicing_changed()

From 73653e9c3a0c5d9d185e0b103b54e88c9bc6f330 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Wed, 1 May 2024 13:36:13 +0000
Subject: [PATCH 02/23] feat: Add BoundsConfigure component within render area

---
 pan3d/dataset_builder.py         |  47 +++++----
 pan3d/dataset_viewer.py          | 158 ++++++++++++-------------------
 pan3d/ui/__init__.py             |   2 +
 pan3d/ui/bounds_configure.py     |  51 ++++++++++
 pan3d/ui/coordinate_configure.py |  47 +++++----
 5 files changed, 161 insertions(+), 144 deletions(-)
 create mode 100644 pan3d/ui/bounds_configure.py

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index 7bdb1b62..7a3b36b4 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -1,4 +1,5 @@
 import os
+import math
 import json
 import pandas
 import pyvista
@@ -20,6 +21,7 @@ def __init__(
         server: Any = None,
         viewer: bool = False,
         catalogs: List[str] = [],
+        resolution: int = 2 ** 7,
     ) -> None:
         """Create an instance of the DatasetBuilder class.
 
@@ -33,6 +35,7 @@ def __init__(
         self._dataset = None
         self._dataset_info = None
         self._da_name = None
+        self._resolution = resolution
 
         self._server = server
         self._catalogs = catalogs
@@ -172,6 +175,7 @@ def data_array_name(self, data_array_name: Optional[str]) -> None:
                 self._viewer._data_array_changed()
                 self._viewer._mesh_changed()
             self._auto_select_coordinates()
+            self._auto_select_slicing()
 
     @property
     def data_array(self) -> Optional[xarray.DataArray]:
@@ -350,27 +354,6 @@ def slicing(self, slicing: Dict[str, List]) -> None:
                         f"Value {value} not applicable for Key {key}. Step value must be <= {key_coord.size}."
                     )
 
-                start_value = value[0]
-                end_value = value[1]
-                if key_coord.dtype.kind in ['O', 'M']:
-                    start_value = pandas.to_datetime(start_value)
-                    end_value = pandas.to_datetime(end_value)
-                elif key_coord.dtype.kind in ['m']:
-                    start_value = pandas.to_timedelta(start_value).total_seconds()
-                    end_value = pandas.to_timedelta(end_value).total_seconds()
-                start_index = None
-                end_index = None
-                for i, c in enumerate(key_coord.to_series()):
-                    if key_coord.dtype.kind in ['O', 'M']:
-                        c = pandas.to_datetime(str(c))
-                    elif key_coord.dtype.kind in ['m']:
-                        c = pandas.to_timedelta(c).total_seconds()
-                    if c >= start_value and start_index is None:
-                        start_index = i
-                    if c <= end_value:
-                        end_index = i
-                slicing[key] = [start_index, end_index, step]
-
         self._algorithm.slicing = slicing
         if self._viewer:
             self._viewer._data_slicing_changed()
@@ -436,6 +419,9 @@ def _auto_select_coordinates(self) -> None:
         if self.dataset is not None and self.data_array_name is not None:
             da = self.dataset[self.data_array_name]
             assigned_coords = []
+            unassigned_axes = [
+                a for a in ["x", "y", "z", "t"] if getattr(self, a) is None
+            ]
             # Prioritize assignment by known names
             for coord_name in da.dims:
                 name = coord_name.lower()
@@ -447,18 +433,29 @@ def _auto_select_coordinates(self) -> None:
                         if (len(accepted) == 1 and accepted == name)
                         or (len(accepted) > 1 and accepted in name)
                     ]
-                    if len(name_match) > 0:
+                    if len(name_match) > 0 and coord_name in unassigned_axes:
                         setattr(self, axis, coord_name)
                         assigned_coords.append(coord_name)
             # Then assign any remaining by index
-            unassigned_axes = [
-                a for a in ["x", "y", "z", "t"] if getattr(self, a) is None
-            ]
             unassigned_coords = [d for d in da.dims if d not in assigned_coords]
             for i, d in enumerate(unassigned_coords):
                 if i < len(unassigned_axes):
                     setattr(self, unassigned_axes[i], d)
 
+    def _auto_select_slicing(self, bounds: Optional[Dict] = None) -> None:
+        """Automatically select slicing for selected data array."""
+        if not bounds:
+            da = self.dataset[self.data_array_name]
+            bounds = {
+                k: [0, da[k].size]
+                for k in da.dims
+            }
+        self.slicing = {
+            k: [v[0], v[1], math.ceil((v[1] - v[0]) / self._resolution)]
+            for k, v in bounds.items()
+        }
+
+
     # -----------------------------------------------------
     # Config logic
     # -----------------------------------------------------
diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index a72ab704..82f337dc 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -18,7 +18,7 @@
 
 from pan3d import catalogs as pan3d_catalogs
 from pan3d.dataset_builder import DatasetBuilder
-from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions
+from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions, BoundsConfigure
 from pan3d.utils import (
     initial_state,
     has_gpu_rendering,
@@ -58,6 +58,7 @@ def __init__(
 
         self.plotter = geovista.GeoPlotter(off_screen=True, notebook=False)
         self.plotter.set_background("lightgrey")
+        self.reset_camera = True
         self.plot_view = None
         self.actor = None
         self.ctrl.get_plotter = lambda: self.plotter
@@ -137,6 +138,9 @@ def ui(self) -> VAppLayout:
                     with html.Div(
                         v_if=("da_active",), style="height: 100%; position: relative"
                     ):
+                        BoundsConfigure(
+                            coordinate_change_bounds_function=self._coordinate_change_bounds,
+                        )
                         RenderOptions()
                         with pyvista.trame.ui.plotter_ui(
                             self.ctrl.get_plotter(),
@@ -232,31 +236,28 @@ def _coordinate_select_axis(
             self.state[current_axis] = None
         if new_axis and new_axis != "undefined":
             self.state[new_axis] = coordinate_name
+        self.reset_camera = True
 
     def _coordinate_change_slice(self, coordinate_name, slice_attribute_name, value):
-        try:
-            value = float(value)
-            coordinate_matches = [
-                (index, coordinate)
-                for index, coordinate in enumerate(self.state.da_coordinates)
-                if coordinate["name"] == coordinate_name
-            ]
-            if len(coordinate_matches) > 0:
-                coord_i, coordinate = coordinate_matches[0]
-                if slice_attribute_name == "step":
-                    if value > 0 and value < coordinate["size"]:
-                        coordinate[slice_attribute_name] = value
-                else:
-                    if (
-                        value >= coordinate["range"][0]
-                        and value <= coordinate["range"][1]
-                    ):
-                        coordinate[slice_attribute_name] = value
+        value = float(value)
+        for coord in self.state.da_coordinates:
+            if coord['name'] == coordinate_name:
+                bounds = coord.get('bounds')
+                if slice_attribute_name == 'start':
+                    bounds[0] = value
+                elif slice_attribute_name == 'stop':
+                    bounds[1] = value
+                elif slice_attribute_name == 'step':
+                    coord.update(dict(step=value))
+                coord.update(dict(bounds=bounds))
+                self.state.dirty("da_coordinates")
+
 
-                self.state.da_coordinates[coord_i] = coordinate
+    def _coordinate_change_bounds(self, coordinate_name, bounds):
+        for coord in self.state.da_coordinates:
+            if coord['name'] == coordinate_name:
+                coord.update(dict(bounds=bounds))
                 self.state.dirty("da_coordinates")
-        except Exception:
-            pass
 
     def _coordinate_toggle_expansion(self, coordinate_name):
         if coordinate_name in self.state.ui_expanded_coordinates:
@@ -275,6 +276,7 @@ def submit():
 
                 self.builder.import_config(json.loads(file_content.decode()))
                 self._mesh_changed()
+                self.reset_camera = True
 
         self.run_as_async(
             submit, loading_state="ui_import_loading", unapplied_changes_state=None
@@ -379,10 +381,12 @@ def plot_mesh(self) -> None:
             mesh,
             **args,
         )
-        if len(self.builder.data_array.shape) > 2:
-            self.plotter.view_isometric()
-        elif not self.state.render_cartographic:
-            self.plotter.view_xy()
+        if self.reset_camera:
+            if len(self.builder.data_array.shape) > 2:
+                self.plotter.view_vector([1, 1, -1], [0, 1, 0])
+            elif not self.state.render_cartographic:
+                self.plotter.view_xy()
+            self.reset_camera = False
 
         if self.plot_view:
             self.ctrl.push_camera()
@@ -475,72 +479,42 @@ def _data_array_changed(self) -> None:
         da_name = self.builder.data_array_name
         self.state.da_coordinates = []
         self.state.ui_expanded_coordinates = []
+        self.reset_camera = True
 
         if dataset is None or da_name is None:
             return
         da = dataset[da_name]
-        if len(da.dims) > 0 and self._ui is not None:
-            self.state.ui_axis_drawer = True
         for key in da.dims:
-            current_coord = da.coords[key]
-            d = current_coord.dtype
-            numeric = True
-            array_min = current_coord.values.min()
-            array_max = current_coord.values.max()
-
-            # make content serializable by its type
-            if d.kind in ["O", "M"]:  # is datetime
-                if not hasattr(array_min, "strftime"):
-                    array_min = pandas.to_datetime(array_min)
-                if not hasattr(array_max, "strftime"):
-                    array_max = pandas.to_datetime(array_max)
-                array_min = array_min.strftime("%b %d %Y %H:%M")
-                array_max = array_max.strftime("%b %d %Y %H:%M")
-                numeric = False
-            elif d.kind in ["m"]:  # is timedelta
-                if not hasattr(array_min, "total_seconds"):
-                    array_min = pandas.to_timedelta(array_min)
-                if not hasattr(array_max, "total_seconds"):
-                    array_max = pandas.to_timedelta(array_max)
-                array_min = array_min.total_seconds()
-                array_max = array_max.total_seconds()
-            elif d.kind in ["i", "u"]:
-                array_min = int(array_min)
-                array_max = int(array_max)
-            elif d.kind in ["f", "c"]:
-                array_min = round(float(array_min), 2)
-                array_max = round(float(array_max), 2)
-
-            coord_attrs = [
-                {"key": str(k), "value": str(v)}
-                for k, v in da.coords[key].attrs.items()
-            ]
-            coord_attrs.append({"key": "dtype", "value": str(da.coords[key].dtype)})
-            coord_attrs.append({"key": "length", "value": int(da.coords[key].size)})
-            coord_attrs.append(
-                {
-                    "key": "range",
-                    "value": [array_min, array_max],
-                }
-            )
             if key not in [c["name"] for c in self.state.da_coordinates]:
-                coord_info = {
+                current_coord = da.coords[key]
+                values = current_coord.values
+                size = current_coord.size
+                coord_range = [
+                    str(round(v)) if isinstance(v, float) else str(v)
+                    for v in [values.item(0), values.item(size-1)]
+                ]
+                dtype = current_coord.dtype
+
+                coord_attrs = [
+                    {"key": str(k), "value": str(v)}
+                    for k, v in da.coords[key].attrs.items()
+                ]
+                coord_attrs.append({"key": "dtype", "value": str(dtype)})
+                coord_attrs.append({"key": "length", "value": int(size)})
+                coord_attrs.append({"key": "range", "value": coord_range})
+                bounds = [0, size - 1]
+                if self.builder.slicing:
+                    slicing = self.builder.slicing.get(key)
+                    if slicing:
+                        bounds = [values.item(slicing[0]), values.item(slicing[1])]
+                self.state.da_coordinates.append({
                     "name": key,
-                    "numeric": numeric,
                     "attrs": coord_attrs,
-                    "size": da.coords[key].size,
-                    "range": [array_min, array_max],
-                }
-                coord_slicing = {
-                    "start": array_min,
-                    "stop": array_max,
+                    "labels": [str(round(v)) if isinstance(v, float) else str(v) for v in values],
+                    "full_bounds": bounds,
+                    "bounds": bounds,
                     "step": 1,
-                }
-                if self.builder.slicing and self.builder.slicing.get(key):
-                    coord_slicing = dict(
-                        zip(["start", "stop", "step"], self.builder.slicing.get(key))
-                    )
-                self.state.da_coordinates.append(dict(**coord_info, **coord_slicing))
+                })
 
             self.state.dirty("da_coordinates")
             self.plotter.clear()
@@ -552,7 +526,10 @@ def _data_slicing_changed(self) -> None:
         for coord in self.state.da_coordinates:
             slicing = self.builder.slicing.get(coord["name"])
             if slicing:
-                coord.update(dict(zip(["start", "stop", "step"], slicing)))
+                bounds = [slicing[0], slicing[1]]
+                if bounds != coord.get('bounds'):
+                    coord.update(dict(bounds=bounds))
+                    self.state.dirty("da_coordinates")
 
     def _time_index_changed(self) -> None:
         dataset = self.builder.dataset
@@ -671,17 +648,8 @@ def _on_change_da_t_index(self, da_t_index, **kwargs):
 
     @change("da_coordinates")
     def _on_change_da_coordinates(self, da_coordinates, **kwargs):
-        if len(da_coordinates) == 0:
-            self.builder.slicing = None
-        else:
-            self.builder.slicing = {
-                coord["name"]: [
-                    coord["start"],
-                    coord["stop"],
-                    coord["step"],
-                ]
-                for coord in da_coordinates
-            }
+        bounds = {c.get('name'): c.get('bounds') for c in da_coordinates}
+        self.builder._auto_select_slicing(bounds)
 
     @change("ui_action_name")
     def _on_change_action_name(self, ui_action_name, **kwargs):
diff --git a/pan3d/ui/__init__.py b/pan3d/ui/__init__.py
index e30946d8..a560aa85 100644
--- a/pan3d/ui/__init__.py
+++ b/pan3d/ui/__init__.py
@@ -2,10 +2,12 @@
 from .main_drawer import MainDrawer
 from .toolbar import Toolbar
 from .render_options import RenderOptions
+from .bounds_configure import BoundsConfigure
 
 __all__ = [
     AxisDrawer,
     MainDrawer,
     Toolbar,
     RenderOptions,
+    BoundsConfigure,
 ]
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
new file mode 100644
index 00000000..c6a48f43
--- /dev/null
+++ b/pan3d/ui/bounds_configure.py
@@ -0,0 +1,51 @@
+from trame.widgets import html
+from trame.widgets import vuetify3 as vuetify
+
+
+class BoundsConfigure(vuetify.VMenu):
+    def __init__(
+        self,
+        coordinate_change_bounds_function,
+        da_coordinates="da_coordinates",
+    ):
+        super().__init__(
+            location="start",
+            transition="slide-y-transition",
+            close_on_content_click=False,
+        )
+        with self:
+            with vuetify.Template(
+                activator="{ props }",
+                __properties=[
+                    ("activator", "v-slot:activator"),
+                ],
+            ):
+                vuetify.VBtn(
+                    v_bind=("props",),
+                    size="small",
+                    icon="mdi-tune-variant",
+                    style="position: absolute; left: 20px; top: 60px; z-index:2",
+                )
+            with vuetify.VCard(classes="pa-6", style="width: 320px"):
+                with vuetify.VRangeSlider(
+                    v_for=(f"coord in {da_coordinates}",),
+                    model_value=("coord.bounds",),
+                    label=("coord.name",),
+                    strict=True,
+                    hide_details=True,
+                    step=1,
+                    min=("coord.full_bounds[0]",),
+                    max=("coord.full_bounds[1]",),
+                    thumb_label=True,
+                    style="width: 250px",
+                    end=(
+                        coordinate_change_bounds_function,
+                        f"[coord.name, $event]"
+                    ),
+                    __events=[("end", "end")],
+                ):
+                    with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
+                        html.Span(
+                            ("{{ coord.labels[modelValue] }}",),
+                            style="white-space: nowrap"
+                        )
diff --git a/pan3d/ui/coordinate_configure.py b/pan3d/ui/coordinate_configure.py
index e46332ac..473d68ff 100644
--- a/pan3d/ui/coordinate_configure.py
+++ b/pan3d/ui/coordinate_configure.py
@@ -49,7 +49,7 @@ def __init__(
                             with vuetify.VSlider(
                                 v_model=(axis_info["index_var"],),
                                 min=0,
-                                max=(f"{coordinate_info}?.size - 1",),
+                                max=(f"{coordinate_info}?.length - 1",),
                                 step=(f"{coordinate_info}?.step",),
                                 classes="mx-5",
                             ):
@@ -59,7 +59,7 @@ def __init__(
                                     vuetify.VTextField(
                                         v_model=(axis_info["index_var"],),
                                         min=0,
-                                        max=(f"{coordinate_info}?.size - 1",),
+                                        max=(f"{coordinate_info}?.length - 1",),
                                         step=(f"{coordinate_info}?.step",),
                                         hide_details=True,
                                         density="compact",
@@ -70,47 +70,45 @@ def __init__(
 
                         else:
                             vuetify.VCardSubtitle(
-                                "Select values",
-                                v_if=(f"{coordinate_info}?.numeric",),
+                                "Select slicing",
                                 classes="mt-3",
                             )
                             with vuetify.VContainer(
                                 classes="d-flex pa-0",
                                 style="column-gap: 3px",
-                                v_if=(f"{coordinate_info}?.numeric",),
                             ):
                                 vuetify.VTextField(
-                                    model_value=(f"{coordinate_info}?.start",),
+                                    model_value=(f"{coordinate_info}?.bounds[0]",),
                                     label="Start",
                                     hide_details=True,
                                     density="compact",
                                     type="number",
-                                    min=(f"{coordinate_info}?.range[0]",),
-                                    max=(f"{coordinate_info}?.range[1]",),
-                                    step="0.01",
+                                    min=(f"{coordinate_info}.full_bounds[0]",),
+                                    max=(f"{coordinate_info}.full_bounds[1]",),
+                                    step="1",
                                     __properties=["min", "max", "step"],
-                                    change_prevent=(
+                                    update=(
                                         coordinate_change_slice_function,
-                                        f"[{coordinate_info}.name, 'start', $event.target.value]",
+                                        f"[{coordinate_info}.name, 'start', $event]",
                                     ),
-                                    __events=[("change_prevent", "change.prevent")],
+                                    __events=[("update", "update:modelValue")],
                                     style="flex-grow: 1",
                                 )
                                 vuetify.VTextField(
-                                    model_value=(f"{coordinate_info}?.stop",),
+                                    model_value=(f"{coordinate_info}?.bounds[1]",),
                                     label="Stop",
                                     hide_details=True,
                                     density="compact",
                                     type="number",
-                                    min=(f"{coordinate_info}?.range[0]",),
-                                    max=(f"{coordinate_info}?.range[1]",),
-                                    step="0.01",
+                                    min=(f"{coordinate_info}.full_bounds[0]",),
+                                    max=(f"{coordinate_info}.full_bounds[1]",),
+                                    step="1",
                                     __properties=["min", "max", "step"],
-                                    change_prevent=(
+                                    update=(
                                         coordinate_change_slice_function,
-                                        f"[{coordinate_info}.name, 'stop', $event.target.value]",
+                                        f"[{coordinate_info}.name, 'stop', $event]",
                                     ),
-                                    __events=[("change_prevent", "change.prevent")],
+                                    __events=[("update", "update:modelValue")],
                                     style="flex-grow: 1",
                                 )
                                 vuetify.VTextField(
@@ -120,13 +118,14 @@ def __init__(
                                     density="compact",
                                     type="number",
                                     min="1",
-                                    max=(f"{coordinate_info}?.size",),
-                                    __properties=["min", "max"],
-                                    change_prevent=(
+                                    max=(f"{coordinate_info}?.length",),
+                                    step="1",
+                                    __properties=["min", "max", "step"],
+                                    update=(
                                         coordinate_change_slice_function,
-                                        f"[{coordinate_info}.name, 'step', $event.target.value]",
+                                        f"[{coordinate_info}.name, 'step', $event]",
                                     ),
-                                    __events=[("change_prevent", "change.prevent")],
+                                    __events=[("update", "update:modelValue")],
                                     style="flex-grow: 1",
                                 )
 

From 24c97adc8af68f8ed3f9b6be6b381bb98ec48977 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Thu, 2 May 2024 14:02:39 +0000
Subject: [PATCH 03/23] feat: Add `resolution` CLI arg and disable auto slicing
 when <= 1

---
 pan3d/dataset_builder.py         | 6 +++++-
 pan3d/serve_viewer.py            | 2 ++
 pan3d/ui/axis_drawer.py          | 3 +++
 pan3d/ui/bounds_configure.py     | 2 ++
 pan3d/ui/coordinate_configure.py | 3 +++
 pan3d/utils.py                   | 1 +
 6 files changed, 16 insertions(+), 1 deletion(-)

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index 7a3b36b4..deab4d5e 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -74,6 +74,7 @@ def viewer(self):
                     da_z=self.z,
                     da_t=self.t,
                     da_t_index=self.t_index,
+                    da_auto_slicing=self._resolution > 1
                 ),
             )
         return self._viewer
@@ -451,7 +452,10 @@ def _auto_select_slicing(self, bounds: Optional[Dict] = None) -> None:
                 for k in da.dims
             }
         self.slicing = {
-            k: [v[0], v[1], math.ceil((v[1] - v[0]) / self._resolution)]
+            k: [
+                v[0], v[1],
+                math.ceil((v[1] - v[0]) / self._resolution) if self._resolution > 1 else 1
+            ]
             for k, v in bounds.items()
         }
 
diff --git a/pan3d/serve_viewer.py b/pan3d/serve_viewer.py
index fc178b17..f55f3ac9 100644
--- a/pan3d/serve_viewer.py
+++ b/pan3d/serve_viewer.py
@@ -10,6 +10,7 @@ def serve():
 
     parser.add_argument("--config_path")
     parser.add_argument("--dataset")
+    parser.add_argument("--resolution", type=int)
     parser.add_argument("--catalogs", nargs="+")
     parser.add_argument("--server", action=BooleanOptionalAction)
     parser.add_argument("--debug", action=BooleanOptionalAction)
@@ -19,6 +20,7 @@ def serve():
     builder = DatasetBuilder(
         dataset=args.dataset,
         catalogs=args.catalogs,
+        resolution=args.resolution,
     )
     if args.config_path:
         builder.import_config(args.config_path)
diff --git a/pan3d/ui/axis_drawer.py b/pan3d/ui/axis_drawer.py
index 12cfadb0..cc8271c4 100644
--- a/pan3d/ui/axis_drawer.py
+++ b/pan3d/ui/axis_drawer.py
@@ -14,6 +14,7 @@ def __init__(
         ui_current_time_string="ui_current_time_string",
         da_active="da_active",
         da_coordinates="da_coordinates",
+        da_auto_slicing="da_auto_slicing",
         da_x="da_x",
         da_y="da_y",
         da_z="da_z",
@@ -68,6 +69,7 @@ def __init__(
                                     CoordinateConfigure(
                                         axes,
                                         da_coordinates,
+                                        da_auto_slicing,
                                         f"{da_coordinates}.find((c) => c.name === {axis['name_var']})",
                                         ui_expanded_coordinates,
                                         ui_current_time_string,
@@ -98,6 +100,7 @@ def __init__(
                             CoordinateConfigure(
                                 axes,
                                 da_coordinates,
+                                da_auto_slicing,
                                 "coord",
                                 ui_expanded_coordinates,
                                 ui_current_time_string,
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index c6a48f43..9f30b266 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -7,8 +7,10 @@ def __init__(
         self,
         coordinate_change_bounds_function,
         da_coordinates="da_coordinates",
+        da_auto_slicing="da_auto_slicing"
     ):
         super().__init__(
+            v_if=(da_auto_slicing,),
             location="start",
             transition="slide-y-transition",
             close_on_content_click=False,
diff --git a/pan3d/ui/coordinate_configure.py b/pan3d/ui/coordinate_configure.py
index 473d68ff..ec5a9355 100644
--- a/pan3d/ui/coordinate_configure.py
+++ b/pan3d/ui/coordinate_configure.py
@@ -6,6 +6,7 @@ def __init__(
         self,
         axes,
         da_coordinates,
+        da_auto_slicing,
         coordinate_info,
         ui_expanded_coordinates,
         ui_current_time_string,
@@ -71,9 +72,11 @@ def __init__(
                         else:
                             vuetify.VCardSubtitle(
                                 "Select slicing",
+                                v_if=(f"!{da_auto_slicing}",),
                                 classes="mt-3",
                             )
                             with vuetify.VContainer(
+                                v_if=(f"!{da_auto_slicing}",),
                                 classes="d-flex pa-0",
                                 style="column-gap: 3px",
                             ):
diff --git a/pan3d/utils.py b/pan3d/utils.py
index 9e2b937c..02843375 100644
--- a/pan3d/utils.py
+++ b/pan3d/utils.py
@@ -64,6 +64,7 @@ def has_gpu_rendering():
     "da_t": None,
     "da_t_index": 0,
     "da_size": None,
+    "da_auto_slicing": True,
     "ui_loading": False,
     "ui_import_loading": False,
     "ui_main_drawer": False,

From cfa631b8296dbcd019945a956ea10f513c3a9410 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Thu, 2 May 2024 14:08:44 +0000
Subject: [PATCH 04/23] style: reformat with black

---
 pan3d/dataset_builder.py     | 18 ++++++++--------
 pan3d/dataset_viewer.py      | 40 ++++++++++++++++++++----------------
 pan3d/ui/bounds_configure.py |  9 +++-----
 3 files changed, 33 insertions(+), 34 deletions(-)

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index deab4d5e..ef79a85e 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -1,7 +1,6 @@
 import os
 import math
 import json
-import pandas
 import pyvista
 import xarray
 
@@ -21,7 +20,7 @@ def __init__(
         server: Any = None,
         viewer: bool = False,
         catalogs: List[str] = [],
-        resolution: int = 2 ** 7,
+        resolution: int = 2**7,
     ) -> None:
         """Create an instance of the DatasetBuilder class.
 
@@ -74,7 +73,7 @@ def viewer(self):
                     da_z=self.z,
                     da_t=self.t,
                     da_t_index=self.t_index,
-                    da_auto_slicing=self._resolution > 1
+                    da_auto_slicing=self._resolution > 1,
                 ),
             )
         return self._viewer
@@ -447,19 +446,18 @@ def _auto_select_slicing(self, bounds: Optional[Dict] = None) -> None:
         """Automatically select slicing for selected data array."""
         if not bounds:
             da = self.dataset[self.data_array_name]
-            bounds = {
-                k: [0, da[k].size]
-                for k in da.dims
-            }
+            bounds = {k: [0, da[k].size] for k in da.dims}
         self.slicing = {
             k: [
-                v[0], v[1],
-                math.ceil((v[1] - v[0]) / self._resolution) if self._resolution > 1 else 1
+                v[0],
+                v[1],
+                math.ceil((v[1] - v[0]) / self._resolution)
+                if self._resolution > 1
+                else 1,
             ]
             for k, v in bounds.items()
         }
 
-
     # -----------------------------------------------------
     # Config logic
     # -----------------------------------------------------
diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index 82f337dc..7cb4a5b7 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -241,21 +241,20 @@ def _coordinate_select_axis(
     def _coordinate_change_slice(self, coordinate_name, slice_attribute_name, value):
         value = float(value)
         for coord in self.state.da_coordinates:
-            if coord['name'] == coordinate_name:
-                bounds = coord.get('bounds')
-                if slice_attribute_name == 'start':
+            if coord["name"] == coordinate_name:
+                bounds = coord.get("bounds")
+                if slice_attribute_name == "start":
                     bounds[0] = value
-                elif slice_attribute_name == 'stop':
+                elif slice_attribute_name == "stop":
                     bounds[1] = value
-                elif slice_attribute_name == 'step':
+                elif slice_attribute_name == "step":
                     coord.update(dict(step=value))
                 coord.update(dict(bounds=bounds))
                 self.state.dirty("da_coordinates")
 
-
     def _coordinate_change_bounds(self, coordinate_name, bounds):
         for coord in self.state.da_coordinates:
-            if coord['name'] == coordinate_name:
+            if coord["name"] == coordinate_name:
                 coord.update(dict(bounds=bounds))
                 self.state.dirty("da_coordinates")
 
@@ -491,7 +490,7 @@ def _data_array_changed(self) -> None:
                 size = current_coord.size
                 coord_range = [
                     str(round(v)) if isinstance(v, float) else str(v)
-                    for v in [values.item(0), values.item(size-1)]
+                    for v in [values.item(0), values.item(size - 1)]
                 ]
                 dtype = current_coord.dtype
 
@@ -507,14 +506,19 @@ def _data_array_changed(self) -> None:
                     slicing = self.builder.slicing.get(key)
                     if slicing:
                         bounds = [values.item(slicing[0]), values.item(slicing[1])]
-                self.state.da_coordinates.append({
-                    "name": key,
-                    "attrs": coord_attrs,
-                    "labels": [str(round(v)) if isinstance(v, float) else str(v) for v in values],
-                    "full_bounds": bounds,
-                    "bounds": bounds,
-                    "step": 1,
-                })
+                self.state.da_coordinates.append(
+                    {
+                        "name": key,
+                        "attrs": coord_attrs,
+                        "labels": [
+                            str(round(v)) if isinstance(v, float) else str(v)
+                            for v in values
+                        ],
+                        "full_bounds": bounds,
+                        "bounds": bounds,
+                        "step": 1,
+                    }
+                )
 
             self.state.dirty("da_coordinates")
             self.plotter.clear()
@@ -527,7 +531,7 @@ def _data_slicing_changed(self) -> None:
             slicing = self.builder.slicing.get(coord["name"])
             if slicing:
                 bounds = [slicing[0], slicing[1]]
-                if bounds != coord.get('bounds'):
+                if bounds != coord.get("bounds"):
                     coord.update(dict(bounds=bounds))
                     self.state.dirty("da_coordinates")
 
@@ -648,7 +652,7 @@ def _on_change_da_t_index(self, da_t_index, **kwargs):
 
     @change("da_coordinates")
     def _on_change_da_coordinates(self, da_coordinates, **kwargs):
-        bounds = {c.get('name'): c.get('bounds') for c in da_coordinates}
+        bounds = {c.get("name"): c.get("bounds") for c in da_coordinates}
         self.builder._auto_select_slicing(bounds)
 
     @change("ui_action_name")
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 9f30b266..7a7f7328 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -7,7 +7,7 @@ def __init__(
         self,
         coordinate_change_bounds_function,
         da_coordinates="da_coordinates",
-        da_auto_slicing="da_auto_slicing"
+        da_auto_slicing="da_auto_slicing",
     ):
         super().__init__(
             v_if=(da_auto_slicing,),
@@ -40,14 +40,11 @@ def __init__(
                     max=("coord.full_bounds[1]",),
                     thumb_label=True,
                     style="width: 250px",
-                    end=(
-                        coordinate_change_bounds_function,
-                        f"[coord.name, $event]"
-                    ),
+                    end=(coordinate_change_bounds_function, "[coord.name, $event]"),
                     __events=[("end", "end")],
                 ):
                     with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
                         html.Span(
                             ("{{ coord.labels[modelValue] }}",),
-                            style="white-space: nowrap"
+                            style="white-space: nowrap",
                         )

From 5cac70ea660dda2cc61fb6ecaa137ae3aec19b2f Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 3 May 2024 16:33:43 +0000
Subject: [PATCH 05/23] fix: Update example config files

---
 examples/example_config_cmip.json   | 17 ++---------------
 examples/example_config_esgf.json   | 20 +-------------------
 examples/example_config_noaa.json   | 12 ++++++------
 examples/example_config_pangeo.json | 22 ----------------------
 examples/example_config_xarray.json | 17 -----------------
 5 files changed, 9 insertions(+), 79 deletions(-)

diff --git a/examples/example_config_cmip.json b/examples/example_config_cmip.json
index 1e71a61b..74907dcc 100644
--- a/examples/example_config_cmip.json
+++ b/examples/example_config_cmip.json
@@ -4,24 +4,11 @@
         "name": "zos",
         "x": "i",
         "y": "j",
-        "t": "time",
-        "t_index": 0
-    },
-    "data_slices": {
-        "j": [
-            0,
-            290,
-            1
-        ],
-        "i": [
-            0,
-            359,
-            1
-        ]
+        "t": "time"
     },
     "ui": {
         "main_drawer": true,
         "axis_drawer": true,
         "expanded_coordinates": []
     }
-}
\ No newline at end of file
+}
diff --git a/examples/example_config_esgf.json b/examples/example_config_esgf.json
index 8efa2d69..752e6ef6 100644
--- a/examples/example_config_esgf.json
+++ b/examples/example_config_esgf.json
@@ -7,25 +7,7 @@
         "name": "sfcWind",
         "x": "lon",
         "y": "lat",
-        "t": "time",
-        "t_index": 0
-    },
-    "data_slices": {
-        "time": [
-            "Apr 01 2000 12:00",
-            "May 30 2001 12:00",
-            1
-        ],
-        "lat": [
-            -89.72,
-            89.72,
-            1
-        ],
-        "lon": [
-            0.42,
-            359.58,
-            1
-        ]
+        "z": "time"
     },
     "ui": {
         "main_drawer": false,
diff --git a/examples/example_config_noaa.json b/examples/example_config_noaa.json
index ca2345e8..1c64177f 100644
--- a/examples/example_config_noaa.json
+++ b/examples/example_config_noaa.json
@@ -9,14 +9,14 @@
     },
     "data_slices": {
         "lat": [
-            -90,
-            90,
-            20
+            0,
+            500,
+            5
         ],
         "lon": [
-            -180,
-            180,
-            20
+            0,
+            500,
+            5
         ]
     },
     "ui": {
diff --git a/examples/example_config_pangeo.json b/examples/example_config_pangeo.json
index 117055df..867892c8 100644
--- a/examples/example_config_pangeo.json
+++ b/examples/example_config_pangeo.json
@@ -11,28 +11,6 @@
         "t": "time",
         "t_index": 0
     },
-    "data_slices": {
-        "time": [
-            142560000,
-            171072000,
-            1
-        ],
-        "Z": [
-            -2912.67,
-            -0.5,
-            1
-        ],
-        "YC": [
-            1000,
-            1999000,
-            1
-        ],
-        "XC": [
-            1000,
-            999000,
-            1
-        ]
-    },
     "ui": {
         "main_drawer": false,
         "axis_drawer": false,
diff --git a/examples/example_config_xarray.json b/examples/example_config_xarray.json
index 298be75d..2d0c2a56 100644
--- a/examples/example_config_xarray.json
+++ b/examples/example_config_xarray.json
@@ -11,23 +11,6 @@
         "t": "month",
         "t_index": 0
     },
-    "data_slices": {
-        "level": [
-            200,
-            850,
-            1
-        ],
-        "latitude": [
-            -90,
-            0,
-            10
-        ],
-        "longitude": [
-            -180,
-            0,
-            10
-        ]
-    },
     "ui": {
         "main_drawer": true,
         "axis_drawer": true,

From 9c9ebf7862dfd93369bf7ea4b4ab46fef51cf902 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 3 May 2024 16:35:29 +0000
Subject: [PATCH 06/23] fix: Correct various bugs and unexpected behavior

---
 pan3d/dataset_builder.py     | 54 +++++++++++++++++++++++++-----------
 pan3d/dataset_viewer.py      | 21 +++++++-------
 pan3d/ui/bounds_configure.py | 40 ++++++++++++++------------
 3 files changed, 71 insertions(+), 44 deletions(-)

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index ef79a85e..92aee6ff 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -35,6 +35,8 @@ def __init__(
         self._dataset_info = None
         self._da_name = None
         self._resolution = resolution
+        self._import_mode = False
+        self._import_viewer_state = {}
 
         self._server = server
         self._catalogs = catalogs
@@ -74,6 +76,7 @@ def viewer(self):
                     da_t=self.t,
                     da_t_index=self.t_index,
                     da_auto_slicing=self._resolution > 1,
+                    **self._import_viewer_state,
                 ),
             )
         return self._viewer
@@ -174,8 +177,9 @@ def data_array_name(self, data_array_name: Optional[str]) -> None:
             if self._viewer:
                 self._viewer._data_array_changed()
                 self._viewer._mesh_changed()
-            self._auto_select_coordinates()
-            self._auto_select_slicing()
+            if not self._import_mode:
+                self._auto_select_coordinates()
+                self._auto_select_slicing()
 
     @property
     def data_array(self) -> Optional[xarray.DataArray]:
@@ -205,7 +209,7 @@ def x(self, x: Optional[str]) -> None:
             acceptable_values = self.dataset[self.data_array_name].dims
             if x not in acceptable_values:
                 raise ValueError(
-                    f"{x} does not exist on data array. Must be one of {acceptable_values}."
+                    f"{x} does not exist on data array. Must be one of {sorted(acceptable_values)}."
                 )
         if self._algorithm.x != x:
             self._algorithm.x = x
@@ -229,7 +233,7 @@ def y(self, y: Optional[str]) -> None:
             acceptable_values = self.dataset[self.data_array_name].dims
             if y not in acceptable_values:
                 raise ValueError(
-                    f"{y} does not exist on data array. Must be one of {acceptable_values}."
+                    f"{y} does not exist on data array. Must be one of {sorted(acceptable_values)}."
                 )
         if self._algorithm.y != y:
             self._algorithm.y = y
@@ -253,7 +257,7 @@ def z(self, z: Optional[str]) -> None:
             acceptable_values = self.dataset[self.data_array_name].dims
             if z not in acceptable_values:
                 raise ValueError(
-                    f"{z} does not exist on data array. Must be one of {acceptable_values}."
+                    f"{z} does not exist on data array. Must be one of {sorted(acceptable_values)}."
                 )
         if self._algorithm.z != z:
             self._algorithm.z = z
@@ -278,7 +282,7 @@ def t(self, t: Optional[str]) -> None:
             acceptable_values = self.dataset[self.data_array_name].dims
             if t not in acceptable_values:
                 raise ValueError(
-                    f"{t} does not exist on data array. Must be one of {acceptable_values}."
+                    f"{t} does not exist on data array. Must be one of {sorted(acceptable_values)}."
                 )
         if self._algorithm.time != t:
             self._algorithm.time = t
@@ -336,15 +340,19 @@ def slicing(self, slicing: Dict[str, List]) -> None:
             for key, value in slicing.items():
                 if not isinstance(key, str):
                     raise ValueError("Keys in slicing must be strings.")
-                if not isinstance(value, list) or len(value) != 3:
+                if (
+                    not isinstance(value, list)
+                    or len(value) != 3
+                    or any(not isinstance(v, int) for v in value)
+                ):
                     raise ValueError(
-                        "Values in slicing must be lists of length 3 ([start, stop, step])."
+                        "Values in slicing must be lists of 3 integers ([start, stop, step])."
                     )
                 da = self.dataset[self.data_array_name]
                 acceptable_coords = da.dims
                 if key not in acceptable_coords:
                     raise ValueError(
-                        f"Key {key} not found in data array. Must be one of {list(acceptable_coords.keys())}."
+                        f"Key {key} not found in data array. Must be one of {sorted(acceptable_coords)}."
                     )
                 key_coord = da[key]
 
@@ -433,7 +441,7 @@ def _auto_select_coordinates(self) -> None:
                         if (len(accepted) == 1 and accepted == name)
                         or (len(accepted) > 1 and accepted in name)
                     ]
-                    if len(name_match) > 0 and coord_name in unassigned_axes:
+                    if len(name_match) > 0 and axis in unassigned_axes:
                         setattr(self, axis, coord_name)
                         assigned_coords.append(coord_name)
             # Then assign any remaining by index
@@ -442,8 +450,14 @@ def _auto_select_coordinates(self) -> None:
                 if i < len(unassigned_axes):
                     setattr(self, unassigned_axes[i], d)
 
-    def _auto_select_slicing(self, bounds: Optional[Dict] = None) -> None:
+    def _auto_select_slicing(
+        self,
+        bounds: Optional[Dict] = None,
+        steps: Optional[Dict] = None,
+    ) -> None:
         """Automatically select slicing for selected data array."""
+        if not self.dataset or not self.data_array_name:
+            return
         if not bounds:
             da = self.dataset[self.data_array_name]
             bounds = {k: [0, da[k].size] for k in da.dims}
@@ -453,6 +467,8 @@ def _auto_select_slicing(self, bounds: Optional[Dict] = None) -> None:
                 v[1],
                 math.ceil((v[1] - v[0]) / self._resolution)
                 if self._resolution > 1
+                else steps.get(k, 1)
+                if steps is not None
                 else 1,
             ]
             for k, v in bounds.items()
@@ -489,22 +505,27 @@ def import_config(self, config_file: Union[str, Path, None]) -> None:
                 "source": "default",
                 "id": origin_config,
             }
+        self._import_mode = True
         self.dataset_info = origin_config
         self.data_array_name = array_config.pop("name")
         for key, value in array_config.items():
             setattr(self, key, value)
         self.slicing = config.get("data_slices")
+        self._import_mode = False
 
+        ui_config = {f"ui_{k}": v for k, v in config.get("ui", {}).items()}
+        render_config = {f"render_{k}": v for k, v in config.get("render", {}).items()}
         if self._viewer:
-            ui_config = {f"ui_{k}": v for k, v in config.get("ui", {}).items()}
-            render_config = {
-                f"render_{k}": v for k, v in config.get("render", {}).items()
-            }
             self._set_state_values(
                 **ui_config,
                 **render_config,
                 ui_action_name=None,
             )
+        else:
+            self._import_viewer_state = dict(
+                **ui_config,
+                **render_config,
+            )
 
     def export_config(self, config_file: Union[str, Path, None] = None) -> None:
         """Export the current state to a JSON configuration file.
@@ -527,8 +548,9 @@ def export_config(self, config_file: Union[str, Path, None] = None) -> None:
                     if getattr(self, key) is not None
                 },
             },
-            "data_slices": self.slicing,
         }
+        if self._algorithm.slicing:
+            config["data_slices"] = self._algorithm.slicing
         if self._viewer:
             state_items = list(self._viewer.state.to_dict().items())
             config["ui"] = {
diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index 7cb4a5b7..c387b970 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -85,6 +85,7 @@ def __init__(
 
     def _on_ready(self, **kwargs):
         self.state.render_auto = True
+        self.reset_camera = True
         self._mesh_changed()
 
     def start(self, **kwargs):
@@ -239,7 +240,7 @@ def _coordinate_select_axis(
         self.reset_camera = True
 
     def _coordinate_change_slice(self, coordinate_name, slice_attribute_name, value):
-        value = float(value)
+        value = int(value)
         for coord in self.state.da_coordinates:
             if coord["name"] == coordinate_name:
                 bounds = coord.get("bounds")
@@ -501,11 +502,7 @@ def _data_array_changed(self) -> None:
                 coord_attrs.append({"key": "dtype", "value": str(dtype)})
                 coord_attrs.append({"key": "length", "value": int(size)})
                 coord_attrs.append({"key": "range", "value": coord_range})
-                bounds = [0, size - 1]
-                if self.builder.slicing:
-                    slicing = self.builder.slicing.get(key)
-                    if slicing:
-                        bounds = [values.item(slicing[0]), values.item(slicing[1])]
+                bounds = [0, size]
                 self.state.da_coordinates.append(
                     {
                         "name": key,
@@ -519,10 +516,9 @@ def _data_array_changed(self) -> None:
                         "step": 1,
                     }
                 )
-
-            self.state.dirty("da_coordinates")
-            self.plotter.clear()
-            self.plotter.view_isometric()
+        self.state.dirty("da_coordinates")
+        self.plotter.clear()
+        self.plotter.view_isometric()
 
     def _data_slicing_changed(self) -> None:
         if self.builder.slicing is None:
@@ -568,6 +564,8 @@ def _mesh_changed(self) -> None:
             self.state.ui_unapplied_changes = False
             return
         total_bytes = da.size * da.dtype.itemsize
+        if total_bytes == 0:
+            self.state.da_size = "0 bytes"
         exponents_map = {0: "bytes", 1: "KB", 2: "MB", 3: "GB"}
         for exponent in sorted(exponents_map.keys(), reverse=True):
             divisor = 1024**exponent
@@ -653,7 +651,8 @@ def _on_change_da_t_index(self, da_t_index, **kwargs):
     @change("da_coordinates")
     def _on_change_da_coordinates(self, da_coordinates, **kwargs):
         bounds = {c.get("name"): c.get("bounds") for c in da_coordinates}
-        self.builder._auto_select_slicing(bounds)
+        steps = {c.get("name"): c.get("step") for c in da_coordinates}
+        self.builder._auto_select_slicing(bounds, steps)
 
     @change("ui_action_name")
     def _on_change_action_name(self, ui_action_name, **kwargs):
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 7a7f7328..091af73e 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -8,6 +8,9 @@ def __init__(
         coordinate_change_bounds_function,
         da_coordinates="da_coordinates",
         da_auto_slicing="da_auto_slicing",
+        da_x="da_x",
+        da_y="da_y",
+        da_z="da_z",
     ):
         super().__init__(
             v_if=(da_auto_slicing,),
@@ -29,22 +32,25 @@ def __init__(
                     style="position: absolute; left: 20px; top: 60px; z-index:2",
                 )
             with vuetify.VCard(classes="pa-6", style="width: 320px"):
-                with vuetify.VRangeSlider(
+                with html.Div(
                     v_for=(f"coord in {da_coordinates}",),
-                    model_value=("coord.bounds",),
-                    label=("coord.name",),
-                    strict=True,
-                    hide_details=True,
-                    step=1,
-                    min=("coord.full_bounds[0]",),
-                    max=("coord.full_bounds[1]",),
-                    thumb_label=True,
-                    style="width: 250px",
-                    end=(coordinate_change_bounds_function, "[coord.name, $event]"),
-                    __events=[("end", "end")],
                 ):
-                    with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
-                        html.Span(
-                            ("{{ coord.labels[modelValue] }}",),
-                            style="white-space: nowrap",
-                        )
+                    with vuetify.VRangeSlider(
+                        v_if=(f"[{da_x}, {da_y}, {da_z}].includes(coord.name)",),
+                        model_value=("coord.bounds",),
+                        label=("coord.name",),
+                        strict=True,
+                        hide_details=True,
+                        step=1,
+                        min=("coord.full_bounds[0]",),
+                        max=("coord.full_bounds[1]",),
+                        thumb_label=True,
+                        style="width: 250px",
+                        end=(coordinate_change_bounds_function, "[coord.name, $event]"),
+                        __events=[("end", "end")],
+                    ):
+                        with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
+                            html.Span(
+                                ("{{ coord.labels[modelValue] }}",),
+                                style="white-space: nowrap",
+                            )

From e08af6b40fb2a17f689071a8b484e69b9157c97b Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 3 May 2024 16:36:19 +0000
Subject: [PATCH 07/23] test: Update expected values in tests

---
 tests/test_builder.py | 17 +++++++++--------
 tests/test_viewer.py  | 14 +++++++-------
 2 files changed, 16 insertions(+), 15 deletions(-)

diff --git a/tests/test_builder.py b/tests/test_builder.py
index 79565c0f..7cca0919 100644
--- a/tests/test_builder.py
+++ b/tests/test_builder.py
@@ -9,8 +9,8 @@
 def test_import_config():
     builder = DatasetBuilder()
     builder.import_config("examples/example_config_noaa.json")
-    # With slicing, data_array has shape (180, 360)
-    assert builder.data_array.size == 64800
+    # With slicing, data_array has shape (100, 100)
+    assert builder.data_array.size == 10000
 
 
 def test_export_config():
@@ -135,30 +135,31 @@ def test_setters_invalid_values():
     # Set a valid value to proceed
     builder.data_array_name = "v"
 
+    acceptable_coord_names = ["latitude", "level", "longitude", "month"]
     # Setting wrong values for x, y, z, t
     with pytest.raises(ValueError) as e:
         builder.x = "foo"
     assert (
         str(e.value)
-        == "foo does not exist on data array. Must be one of ('month', 'level', 'latitude', 'longitude')."
+        == f"foo does not exist on data array. Must be one of {acceptable_coord_names}."
     )
     with pytest.raises(ValueError) as e:
         builder.y = "foo"
     assert (
         str(e.value)
-        == "foo does not exist on data array. Must be one of ('month', 'level', 'latitude', 'longitude')."
+        == f"foo does not exist on data array. Must be one of {acceptable_coord_names}."
     )
     with pytest.raises(ValueError) as e:
         builder.z = "foo"
     assert (
         str(e.value)
-        == "foo does not exist on data array. Must be one of ('month', 'level', 'latitude', 'longitude')."
+        == f"foo does not exist on data array. Must be one of {acceptable_coord_names}."
     )
     with pytest.raises(ValueError) as e:
         builder.t = "foo"
     assert (
         str(e.value)
-        == "foo does not exist on data array. Must be one of ('month', 'level', 'latitude', 'longitude')."
+        == f"foo does not exist on data array. Must be one of {acceptable_coord_names}."
     )
 
     # Set valid values to proceed
@@ -183,13 +184,13 @@ def test_setters_invalid_values():
         builder.slicing = {"foo": []}
     assert (
         str(e.value)
-        == "Values in slicing must be lists of length 3 ([start, stop, step])."
+        == "Values in slicing must be lists of 3 integers ([start, stop, step])."
     )
     with pytest.raises(ValueError) as e:
         builder.slicing = {"foo": [0, 1, 1]}
     assert (
         str(e.value)
-        == "Key foo not found in data array. Must be one of ['longitude', 'latitude', 'level', 'month']."
+        == f"Key foo not found in data array. Must be one of {acceptable_coord_names}."
     )
     with pytest.raises(ValueError) as e:
         builder.slicing = {"month": [-1, 10, 10]}
diff --git a/tests/test_viewer.py b/tests/test_viewer.py
index 303b5a71..83b77ebc 100644
--- a/tests/test_viewer.py
+++ b/tests/test_viewer.py
@@ -7,7 +7,7 @@ def push_builder_state(builder):
     builder.z = "level"
     builder.t = "month"
     builder.t_index = 1
-    builder.slicing = {"longitude": [0, 90, 2]}
+    builder.slicing = {"longitude": [0, 90, 1], "latitude": [0, 100, 1]}
 
 
 def push_viewer_state(viewer):
@@ -51,7 +51,7 @@ def assert_viewer_state(viewer):
     assert viewer.state.da_z == "level"
     assert viewer.state.da_t == "month"
     assert viewer.state.da_t_index == 1
-    assert viewer.state.da_size == "345 KB"
+    assert viewer.state.da_size == "211 KB"
     assert viewer.state.da_vars == [
         {"name": "z", "id": 0},
         {"name": "u", "id": 1},
@@ -128,10 +128,10 @@ def test_viewer_export():
     assert viewer.state.state_export["data_array"]["z"] == "level"
     assert viewer.state.state_export["data_array"]["t"] == "month"
     assert viewer.state.state_export["data_array"]["t_index"] == 1
-    assert viewer.state.state_export["data_slices"]["longitude"] == [-180.0, 179.25, 1]
-    assert viewer.state.state_export["data_slices"]["latitude"] == [-90.0, 90.0, 1]
-    assert viewer.state.state_export["data_slices"]["level"] == [200, 850, 1]
-    assert viewer.state.state_export["data_slices"]["month"] == [1, 7, 1]
+    assert viewer.state.state_export["data_slices"]["longitude"] == [0, 480, 4]
+    assert viewer.state.state_export["data_slices"]["latitude"] == [0, 241, 2]
+    assert viewer.state.state_export["data_slices"]["level"] == [0, 3, 1]
+    assert viewer.state.state_export["data_slices"]["month"] == [0, 2, 1]
     assert not viewer.state.state_export["ui"]["main_drawer"]
     assert not viewer.state.state_export["ui"]["axis_drawer"]
     assert viewer.state.state_export["ui"]["unapplied_changes"]
@@ -179,7 +179,7 @@ def test_sync_from_viewer_ui_functions():
     viewer._coordinate_select_axis("month", None, "da_t")
     viewer._coordinate_change_slice("longitude", "start", 0)
     viewer._coordinate_change_slice("longitude", "stop", 90)
-    viewer._coordinate_change_slice("longitude", "step", 2)
+    viewer._coordinate_change_bounds("latitude", [0, 100])
 
     viewer.state.flush()
     assert_builder_state(builder)

From faabc39df0ef5edbdb5caa69efb34dfcaa260876 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Thu, 16 May 2024 16:14:44 +0000
Subject: [PATCH 08/23] fix: Move default resolution value (cmd arg is None if
 not specified)

---
 pan3d/dataset_builder.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py
index 92aee6ff..960d2109 100644
--- a/pan3d/dataset_builder.py
+++ b/pan3d/dataset_builder.py
@@ -20,7 +20,7 @@ def __init__(
         server: Any = None,
         viewer: bool = False,
         catalogs: List[str] = [],
-        resolution: int = 2**7,
+        resolution: int = None,
     ) -> None:
         """Create an instance of the DatasetBuilder class.
 
@@ -34,7 +34,7 @@ def __init__(
         self._dataset = None
         self._dataset_info = None
         self._da_name = None
-        self._resolution = resolution
+        self._resolution = resolution or 2**7
         self._import_mode = False
         self._import_viewer_state = {}
 

From ad2fcdc14b202ba548433db69705827af90a8a10 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Mon, 20 May 2024 23:45:29 +0000
Subject: [PATCH 09/23] feat: Generate cube face preview images and set up cube
 mode state vars

---
 pan3d/dataset_viewer.py      | 81 ++++++++++++++++++++++++++++++++++++
 pan3d/ui/bounds_configure.py | 49 +++++++++++++++++++++-
 pan3d/ui/render_options.py   |  2 +
 pan3d/utils.py               |  5 +++
 4 files changed, 135 insertions(+), 2 deletions(-)

diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index c387b970..058491c6 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -4,6 +4,10 @@
 import pandas
 import pyvista
 import geovista
+import numpy
+import base64
+from io import BytesIO
+from PIL import Image
 from pathlib import Path
 from typing import Dict, List, Optional, Union
 
@@ -152,6 +156,9 @@ def ui(self) -> VAppLayout:
                             self.ctrl.reset_camera = plot_view.reset_camera
                             self.ctrl.push_camera = plot_view.push_camera
                             self.plot_view = plot_view
+        # turn on axis orientation widget by default with state var from pyvista
+        # (typo in visibility is intentional, done to match pyvista)
+        self.state[f"{self.ctrl.get_plotter()._id_name}_axis_visiblity"] = True
         return self._ui
 
     # -----------------------------------------------------
@@ -530,6 +537,7 @@ def _data_slicing_changed(self) -> None:
                 if bounds != coord.get("bounds"):
                     coord.update(dict(bounds=bounds))
                     self.state.dirty("da_coordinates")
+        self._generate_preview()
 
     def _time_index_changed(self) -> None:
         dataset = self.builder.dataset
@@ -556,6 +564,75 @@ def _time_index_changed(self) -> None:
                     current_time = pandas.to_timedelta(current_time)
                 current_time = f"{current_time.total_seconds()} seconds"
             self.state.ui_current_time_string = str(current_time)
+            self._generate_preview()
+
+    def _generate_preview(self) -> None:
+        if (
+            self.builder.dataset is None
+            or self.builder.data_array_name is None
+            or self.builder.slicing is None
+        ):
+            return
+        preview_slicing = {}
+        if self.builder.t is not None and self.builder.t_index is not None:
+            preview_slicing[self.builder.t] = self.builder.t_index
+
+        face_options = []
+        if self.builder.z is not None:
+            face_options += ["+Z", "-Z"]
+        if self.builder.y is not None:
+            face_options += ["+Y", "-Y"]
+        if self.builder.x is not None:
+            face_options += ["+X", "-X"]
+        self.state.cube_preview_face_options = face_options
+        if self.state.cube_preview_face not in face_options and len(face_options):
+            self.state.cube_preview_face = face_options[0]
+
+        axis_name = None
+        if "X" in self.state.cube_preview_face:
+            self.state.cube_preview_axes = dict(
+                x=self.builder.y,
+                y=self.builder.z,
+            )
+            axis_name = self.builder.x
+        elif "Y" in self.state.cube_preview_face:
+            self.state.cube_preview_axes = dict(
+                x=self.builder.x,
+                y=self.builder.z,
+            )
+            axis_name = self.builder.y
+        elif "Z" in self.state.cube_preview_face:
+            self.state.cube_preview_axes = dict(
+                x=self.builder.x,
+                y=self.builder.y,
+            )
+            axis_name = self.builder.z
+
+        if axis_name is not None:
+            axis_slicing = self.builder.slicing.get(axis_name)
+            if axis_slicing is not None:
+                preview_slicing[axis_name] = (
+                    axis_slicing[0]
+                    if "+" in self.state.cube_preview_face
+                    else axis_slicing[1] - 1
+                )
+
+        data = (
+            self.builder.dataset[self.builder.data_array_name]
+            .isel(preview_slicing)
+            .to_numpy()
+        )
+        normalized_data = numpy.vectorize(
+            lambda x, x_min, x_max: (x - x_min) / (x_max - x_min) * 255
+        )(data, numpy.min(data), numpy.max(data)).astype(numpy.uint8)
+        img = Image.fromarray(normalized_data)
+        img = img.rotate(180)  # match default axis orientation
+        buffer = BytesIO()
+        img.save(buffer, format="PNG")
+        encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
+
+        # save encoded to state
+        self.state.cube_preview = f"data:image/png;base64,{encoded}"
 
     def _mesh_changed(self) -> None:
         da = self.builder.data_array
@@ -692,3 +769,7 @@ def _on_change_render_options(
             scalar_warp=render_scalar_warp,
             cartographic=render_cartographic,
         )
+
+    @change("cube_preview_face")
+    def _on_change_cube_preview_face(self, cube_preview_face, **kwargs):
+        self._generate_preview()
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 091af73e..ad9b4ef6 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -11,12 +11,22 @@ def __init__(
         da_x="da_x",
         da_y="da_y",
         da_z="da_z",
+        da_t="da_t",
+        da_t_index="da_t_index",
+        current_time="ui_current_time_string",
+        cube_view_mode="cube_view_mode",
+        cube_preview="cube_preview",
+        cube_preview_face="cube_preview_face",
+        cube_preview_face_options="cube_preview_face_options",
+        cube_preview_axes="cube_preview_axes",
     ):
         super().__init__(
             v_if=(da_auto_slicing,),
             location="start",
             transition="slide-y-transition",
             close_on_content_click=False,
+            persistent=True,
+            no_click_animation=True,
         )
         with self:
             with vuetify.Template(
@@ -31,12 +41,30 @@ def __init__(
                     icon="mdi-tune-variant",
                     style="position: absolute; left: 20px; top: 60px; z-index:2",
                 )
-            with vuetify.VCard(classes="pa-6", style="width: 320px"):
+            with vuetify.VCard(classes="pa-3", style="width: 325px"):
+                vuetify.VCardTitle("Configure Bounds")
+                vuetify.VSelect(
+                    v_if=(cube_view_mode,),
+                    v_model=(cube_preview_face,),
+                    items=(cube_preview_face_options, []),
+                    label="Face",
+                    hide_details=True,
+                    style="float:right",
+                )
+                vuetify.VCheckbox(
+                    v_model=(cube_view_mode,), label="Cube View", hide_details=True
+                )
                 with html.Div(
                     v_for=(f"coord in {da_coordinates}",),
                 ):
                     with vuetify.VRangeSlider(
-                        v_if=(f"[{da_x}, {da_y}, {da_z}].includes(coord.name)",),
+                        v_if=(
+                            f"""
+                            ({da_x} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('X'))) ||
+                            ({da_y} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('Y'))) ||
+                            ({da_z} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('Z')))
+                        """,
+                        ),
                         model_value=("coord.bounds",),
                         label=("coord.name",),
                         strict=True,
@@ -54,3 +82,20 @@ def __init__(
                                 ("{{ coord.labels[modelValue] }}",),
                                 style="white-space: nowrap",
                             )
+                with html.Div(
+                    v_for=(f"coord in {da_coordinates}",),
+                ):
+                    with vuetify.VSlider(
+                        v_model=(da_t_index),
+                        v_if=(f"{da_t} === coord.name",),
+                        label=("coord.name",),
+                        min=("coord.full_bounds[0]",),
+                        max=("coord.full_bounds[1] - 1",),
+                        step=1,
+                        thumb_label=True,
+                    ):
+                        with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
+                            html.Span(
+                                ("{{ coord.labels[modelValue] }}",),
+                                style="white-space: nowrap",
+                            )
diff --git a/pan3d/ui/render_options.py b/pan3d/ui/render_options.py
index 2a61a7d6..26ffb49e 100644
--- a/pan3d/ui/render_options.py
+++ b/pan3d/ui/render_options.py
@@ -19,6 +19,8 @@ def __init__(
             location="start",
             transition="slide-y-transition",
             close_on_content_click=False,
+            persistent=True,
+            no_click_animation=True,
         )
         with self:
             with vuetify.Template(
diff --git a/pan3d/utils.py b/pan3d/utils.py
index 02843375..85660702 100644
--- a/pan3d/utils.py
+++ b/pan3d/utils.py
@@ -65,6 +65,11 @@ def has_gpu_rendering():
     "da_t_index": 0,
     "da_size": None,
     "da_auto_slicing": True,
+    "cube_view_mode": True,
+    "cube_preview": None,
+    "cube_preview_face": "+Z",
+    "cube_preview_face_options": [],
+    "cube_preview_axes": None,
     "ui_loading": False,
     "ui_import_loading": False,
     "ui_main_drawer": False,

From 1d9c89892df76ee0d505218c563c52a1e36423ec Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Mon, 20 May 2024 23:49:05 +0000
Subject: [PATCH 10/23] feat: Add PreviewBounds component, written with Vue

---
 pan3d/dataset_viewer.py                      |   2 +
 pan3d/ui/bounds_configure.py                 |  12 +
 pan3d/ui/preview_bounds.py                   |  14 +
 pan3d/ui/vue/.eslintrc.cjs                   |  12 +
 pan3d/ui/vue/.gitignore                      |   7 +
 pan3d/ui/vue/module/__init__.py              |  13 +
 pan3d/ui/vue/package.json                    |  42 +++
 pan3d/ui/vue/src/components/PreviewBounds.js | 266 +++++++++++++++++++
 pan3d/ui/vue/src/components/index.js         |   5 +
 pan3d/ui/vue/src/main.js                     |   7 +
 pan3d/ui/vue/vite.config.js                  |  21 ++
 11 files changed, 401 insertions(+)
 create mode 100644 pan3d/ui/preview_bounds.py
 create mode 100644 pan3d/ui/vue/.eslintrc.cjs
 create mode 100644 pan3d/ui/vue/.gitignore
 create mode 100644 pan3d/ui/vue/module/__init__.py
 create mode 100644 pan3d/ui/vue/package.json
 create mode 100644 pan3d/ui/vue/src/components/PreviewBounds.js
 create mode 100644 pan3d/ui/vue/src/components/index.js
 create mode 100644 pan3d/ui/vue/src/main.js
 create mode 100644 pan3d/ui/vue/vite.config.js

diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index 058491c6..dcd5588a 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -23,6 +23,7 @@
 from pan3d import catalogs as pan3d_catalogs
 from pan3d.dataset_builder import DatasetBuilder
 from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions, BoundsConfigure
+from pan3d.ui.vue import module
 from pan3d.utils import (
     initial_state,
     has_gpu_rendering,
@@ -56,6 +57,7 @@ def __init__(
             builder._viewer = self
         self.builder = builder
         self.server = get_server(server, client_type="vue3")
+        self.server.enable_module(module)
         self.current_event_loop = asyncio.get_event_loop()
         self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
         self._ui = None
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index ad9b4ef6..7163a8cc 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -1,6 +1,8 @@
 from trame.widgets import html
 from trame.widgets import vuetify3 as vuetify
 
+from .preview_bounds import PreviewBounds
+
 
 class BoundsConfigure(vuetify.VMenu):
     def __init__(
@@ -54,6 +56,16 @@ def __init__(
                 vuetify.VCheckbox(
                     v_model=(cube_view_mode,), label="Cube View", hide_details=True
                 )
+                with html.Div(v_if=(cube_view_mode,)):
+                    PreviewBounds(
+                        preview=(cube_preview,),
+                        axes=(cube_preview_axes,),
+                        coordinates=(da_coordinates,),
+                        update_bounds=(
+                            coordinate_change_bounds_function,
+                            "[$event.name, $event.bounds]",
+                        ),
+                    )
                 with html.Div(
                     v_for=(f"coord in {da_coordinates}",),
                 ):
diff --git a/pan3d/ui/preview_bounds.py b/pan3d/ui/preview_bounds.py
new file mode 100644
index 00000000..b99f9d6a
--- /dev/null
+++ b/pan3d/ui/preview_bounds.py
@@ -0,0 +1,14 @@
+from trame_client.widgets.core import HtmlElement
+
+
+class PreviewBounds(HtmlElement):
+    def __init__(self, children=None, **kwargs):
+        super().__init__("preview-bounds", children, **kwargs)
+        self._attr_names += [
+            "preview",
+            "axes",
+            "coordinates",
+        ]
+        self._event_names += [
+            ("update_bounds", "update-bounds"),
+        ]
diff --git a/pan3d/ui/vue/.eslintrc.cjs b/pan3d/ui/vue/.eslintrc.cjs
new file mode 100644
index 00000000..425c253e
--- /dev/null
+++ b/pan3d/ui/vue/.eslintrc.cjs
@@ -0,0 +1,12 @@
+/* eslint-env node */
+module.exports = {
+  root: true,
+  extends: [
+    "plugin:vue/vue3-essential",
+    "eslint:recommended",
+    "@vue/eslint-config-prettier",
+  ],
+  parserOptions: {
+    ecmaVersion: "latest",
+  },
+};
diff --git a/pan3d/ui/vue/.gitignore b/pan3d/ui/vue/.gitignore
new file mode 100644
index 00000000..7dff8cea
--- /dev/null
+++ b/pan3d/ui/vue/.gitignore
@@ -0,0 +1,7 @@
+serve
+serve/*.map
+serve/*.html
+serve/*.common.*
+serve/*-light.*
+serve/*.umd.js
+package-lock.json
diff --git a/pan3d/ui/vue/module/__init__.py b/pan3d/ui/vue/module/__init__.py
new file mode 100644
index 00000000..1c3fdd17
--- /dev/null
+++ b/pan3d/ui/vue/module/__init__.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+
+# Compute local path to serve
+serve_path = str(Path(__file__).parent.with_name("serve").resolve())
+
+# Serve directory for JS/CSS files
+serve = {"__pan3d_components": serve_path}
+
+# List of JS files to load (usually from the serve path above)
+scripts = ["__pan3d_components/pan3d-components.umd.js"]
+
+# List of Vue plugins to install/load
+vue_use = ["pan3d_components"]
diff --git a/pan3d/ui/vue/package.json b/pan3d/ui/vue/package.json
new file mode 100644
index 00000000..76ced23b
--- /dev/null
+++ b/pan3d/ui/vue/package.json
@@ -0,0 +1,42 @@
+{
+  "name": "pan3d-components",
+  "version": "0.0.0",
+  "description": "Vue component for pan3d",
+  "main": "./dist/pan3d-components.umd.js",
+  "unpkg": "./dist/pan3d-components.umd.js",
+  "exports": {
+    ".": {
+      "require": "./dist/pan3d-components.umd.js"
+    }
+  },
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build --emptyOutDir",
+    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore --ignore-pattern public",
+    "semantic-release": "semantic-release"
+  },
+  "dependencies": {},
+  "peerDependencies": {
+    "vue": "^2.7.0 || >=3.0.0"
+  },
+  "devDependencies": {
+    "@vue/eslint-config-prettier": "^7.0.0",
+    "eslint": "^8.33.0",
+    "eslint-plugin-vue": "^9.3.0",
+    "prettier": "^2.7.1",
+    "vite": "^4.1.0",
+    "vue": "^3.0.0"
+  },
+  "author": "Kitware Inc",
+  "license": "MIT",
+  "keywords": [
+    "Kitware",
+    "pan3d"
+  ],
+  "files": [
+    "dist/*",
+    "src/*",
+    "*.json",
+    "*.js"
+  ]
+}
diff --git a/pan3d/ui/vue/src/components/PreviewBounds.js b/pan3d/ui/vue/src/components/PreviewBounds.js
new file mode 100644
index 00000000..6e5e397b
--- /dev/null
+++ b/pan3d/ui/vue/src/components/PreviewBounds.js
@@ -0,0 +1,266 @@
+const { ref, toRefs, watch, onMounted } = window.Vue;
+
+export default {
+  props: {
+    preview: {
+      default: null,
+    },
+    axes: {
+      default: null,
+    },
+    coordinates: {
+      default: null,
+    },
+  },
+  emits: ["update-bounds"],
+  setup(props, { emit }) {
+    const svg = ref();
+    const { preview, coordinates } = toRefs(props);
+    const previewImage = ref();
+    const previewShape = ref();
+    const viewBox = ref();
+    const boundsBox = ref([0, 0, 0, 0]);
+    let dragging = null;
+
+    function updateViewBox() {
+      const i = new Image();
+      i.onload = () => {
+        const factor = 300 / i.width;
+        previewShape.value = [300, Math.round(i.height * factor)];
+        viewBox.value = `0 0 ${previewShape.value.join(" ")}`;
+      };
+      i.src = preview.value;
+    }
+
+    function updateBoundsBox() {
+      if (!previewShape.value) return;
+      let xMin;
+      let xMax;
+      let yMin;
+      let yMax;
+      props.coordinates.forEach((coord) => {
+        if (coord.name === props.axes.x) {
+          xMin =
+            previewShape.value[0] -
+            ((coord.bounds[1] - coord.full_bounds[0]) /
+              (coord.full_bounds[1] - coord.full_bounds[0])) *
+              previewShape.value[0];
+          xMax =
+            previewShape.value[0] -
+            ((coord.bounds[0] - coord.full_bounds[0]) /
+              (coord.full_bounds[1] - coord.full_bounds[0])) *
+              previewShape.value[0];
+        } else if (coord.name === props.axes.y) {
+          yMin =
+            previewShape.value[1] -
+            ((coord.bounds[1] - coord.full_bounds[0]) /
+              (coord.full_bounds[1] - coord.full_bounds[0])) *
+              previewShape.value[1];
+          yMax =
+            previewShape.value[1] -
+            ((coord.bounds[0] - coord.full_bounds[0]) /
+              (coord.full_bounds[1] - coord.full_bounds[0])) *
+              previewShape.value[1];
+        }
+      });
+      boundsBox.value = [xMin, yMin, xMax, yMax];
+    }
+
+    function onMousePress(e) {
+      const allowance = 5;
+      const svgBounds = svg.value.getBoundingClientRect();
+      const location = [e.clientX - svgBounds.x, e.clientY - svgBounds.y];
+      if (
+        location[0] >= boundsBox.value[0] - allowance &&
+        location[0] <= boundsBox.value[2] + allowance &&
+        location[1] >= boundsBox.value[1] - allowance &&
+        location[1] <= boundsBox.value[3] + allowance
+      ) {
+        dragging = {
+          from: location,
+          xMin: false,
+          xMax: false,
+          yMin: false,
+          yMax: false,
+          wholeBox: true,
+        };
+        if (Math.abs(location[0] - boundsBox.value[0]) <= allowance * 2) {
+          dragging.xMin = true;
+          dragging.wholeBox = false;
+        }
+        if (Math.abs(location[0] - boundsBox.value[2]) <= allowance * 2) {
+          dragging.xMax = true;
+          dragging.wholeBox = false;
+        }
+        if (Math.abs(location[1] - boundsBox.value[1]) <= allowance * 2) {
+          dragging.yMin = true;
+          dragging.wholeBox = false;
+        }
+        if (Math.abs(location[1] - boundsBox.value[3]) <= allowance * 2) {
+          dragging.yMax = true;
+          dragging.wholeBox = false;
+        }
+      }
+    }
+
+    function onMouseMove(e) {
+      if (dragging) {
+        const svgBounds = svg.value.getBoundingClientRect();
+        const location = [e.clientX - svgBounds.x, e.clientY - svgBounds.y];
+        const [dx, dy] = [
+          location[0] - dragging.from[0],
+          location[1] - dragging.from[1],
+        ];
+        dragging.from = location;
+        let xMin = boundsBox.value[0];
+        let yMin = boundsBox.value[1];
+        let xMax = boundsBox.value[2];
+        let yMax = boundsBox.value[3];
+        if (dragging.xMin || dragging.xMax || dragging.wholeBox) {
+          if (dragging.xMin || dragging.wholeBox) xMin += dx;
+          if (dragging.xMax || dragging.wholeBox) xMax += dx;
+          if (xMin >= 0 && xMax <= previewShape.value[0]) {
+            boundsBox.value[0] = xMin;
+            boundsBox.value[2] = xMax;
+          }
+        }
+        if (dragging.yMin || dragging.yMax || dragging.wholeBox) {
+          if (dragging.yMin || dragging.wholeBox) yMin += dy;
+          if (dragging.yMax || dragging.wholeBox) yMax += dy;
+          if (yMin >= 0 && yMax <= previewShape.value[1]) {
+            boundsBox.value[1] = yMin;
+            boundsBox.value[3] = yMax;
+          }
+        }
+      }
+    }
+
+    function onMouseRelease() {
+      if (dragging) {
+        dragging = null;
+        let xRange;
+        let yRange;
+        props.coordinates.forEach((coord) => {
+          if (coord.name === props.axes.x) {
+            xRange = [
+              ((previewShape.value[0] - boundsBox.value[2]) /
+                previewShape.value[0]) *
+                (coord.full_bounds[1] - coord.full_bounds[0]) +
+                coord.full_bounds[0],
+              ((previewShape.value[0] - boundsBox.value[0]) /
+                previewShape.value[0]) *
+                (coord.full_bounds[1] - coord.full_bounds[0]) +
+                coord.full_bounds[0],
+            ];
+          } else if (coord.name === props.axes.y) {
+            yRange = [
+              ((previewShape.value[1] - boundsBox.value[3]) /
+                previewShape.value[1]) *
+                (coord.full_bounds[1] - coord.full_bounds[0]) +
+                coord.full_bounds[0],
+              ((previewShape.value[1] - boundsBox.value[1]) /
+                previewShape.value[1]) *
+                (coord.full_bounds[1] - coord.full_bounds[0]) +
+                coord.full_bounds[0],
+            ];
+          }
+        });
+        emit("update-bounds", {
+          name: props.axes.x,
+          bounds: xRange.map((v) => Math.round(v)),
+        });
+        emit("update-bounds", {
+          name: props.axes.y,
+          bounds: yRange.map((v) => Math.round(v)),
+        });
+      }
+    }
+
+    onMounted(updateViewBox);
+    watch(preview, updateViewBox);
+    watch(previewShape, updateBoundsBox, { deep: true });
+    watch(coordinates, updateBoundsBox, { deep: true });
+
+    return {
+      svg,
+      preview,
+      previewImage,
+      viewBox,
+      boundsBox,
+      onMouseMove,
+      onMousePress,
+      onMouseRelease,
+      color: "rgb(255,0,0)",
+      radius: 7,
+    };
+  },
+  template: `
+  <svg
+    ref="svg"
+    :viewBox="viewBox"
+    @mousedown.prevent="onMousePress"
+    @mousemove="onMouseMove"
+    @mouseup="onMouseRelease"
+    @mouseleave="onMouseRelease"
+    style="cursor: pointer;"
+  >
+    <image
+      ref="previewImage"
+      :href="preview"
+      style="width: 300px"
+    />
+    <circle
+      :cx="boundsBox[0]"
+      :cy="boundsBox[1]"
+      :r="radius"
+      :fill="color"
+    />
+    <circle
+      :cx="boundsBox[2]"
+      :cy="boundsBox[1]"
+      :r="radius"
+      :fill="color"
+    />
+    <circle
+      :cx="boundsBox[0]"
+      :cy="boundsBox[3]"
+      :r="radius"
+      :fill="color"
+    />
+    <circle
+      :cx="boundsBox[2]"
+      :cy="boundsBox[3]"
+      :r="radius"
+      :fill="color"
+    />
+    <line
+      :x1="boundsBox[0]"
+      :y1="boundsBox[1]"
+      :x2="boundsBox[2]"
+      :y2="boundsBox[1]"
+      :style="'stroke-width:5;stroke:'+color"
+    />
+    <line
+      :x1="boundsBox[0]"
+      :y1="boundsBox[1]"
+      :x2="boundsBox[0]"
+      :y2="boundsBox[3]"
+      :style="'stroke-width:5;stroke:'+color"
+    />
+    <line
+      :x1="boundsBox[2]"
+      :y1="boundsBox[1]"
+      :x2="boundsBox[2]"
+      :y2="boundsBox[3]"
+      :style="'stroke-width:5;stroke:'+color"
+    />
+    <line
+      :x1="boundsBox[0]"
+      :y1="boundsBox[3]"
+      :x2="boundsBox[2]"
+      :y2="boundsBox[3]"
+      :style="'stroke-width:5;stroke:'+color"
+    />
+  </svg>
+`,
+};
diff --git a/pan3d/ui/vue/src/components/index.js b/pan3d/ui/vue/src/components/index.js
new file mode 100644
index 00000000..4bd00c06
--- /dev/null
+++ b/pan3d/ui/vue/src/components/index.js
@@ -0,0 +1,5 @@
+import PreviewBounds from "./PreviewBounds";
+
+export default {
+  PreviewBounds,
+};
diff --git a/pan3d/ui/vue/src/main.js b/pan3d/ui/vue/src/main.js
new file mode 100644
index 00000000..55d953f7
--- /dev/null
+++ b/pan3d/ui/vue/src/main.js
@@ -0,0 +1,7 @@
+import components from "./components";
+
+export function install(Vue) {
+  Object.keys(components).forEach((name) => {
+    Vue.component(name, components[name]);
+  });
+}
diff --git a/pan3d/ui/vue/vite.config.js b/pan3d/ui/vue/vite.config.js
new file mode 100644
index 00000000..fe2f1211
--- /dev/null
+++ b/pan3d/ui/vue/vite.config.js
@@ -0,0 +1,21 @@
+export default {
+  base: "./",
+  build: {
+    lib: {
+      entry: "./src/main.js",
+      name: "pan3d_components",
+      formats: ["umd"],
+      fileName: "pan3d-components",
+    },
+    rollupOptions: {
+      external: ["vue"],
+      output: {
+        globals: {
+          vue: "Vue",
+        },
+      },
+    },
+    outDir: "serve",
+    assetsDir: ".",
+  },
+};

From ce688ed14488a793cbf1827f7db79ec84fd881fc Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Thu, 23 May 2024 17:41:57 +0000
Subject: [PATCH 11/23] fix: Add npm installation steps to CI tests

---
 .github/workflows/test.yml | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 197cce4c..60456256 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -55,6 +55,20 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
 
+    - name: Set up NPM
+      uses: actions/setup-node@v4
+      with:
+        node-version: 18
+    - name: Install Vue component dependencies
+      run: npm install
+      working-directory: pan3d/ui/vue
+    - name: Lint Vue components
+      run: npm run lint
+      working-directory: pan3d/ui/vue
+    - name: Build Vue components
+      run: npm run build
+      working-directory: pan3d/ui/vue
+
     - name: Install and Run Tests
       run: |
         pip install .

From 1532b5ae25084aa207ddd28c6b973344d596be07 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 24 May 2024 13:15:42 +0000
Subject: [PATCH 12/23] fix: apply suggested usability changes

---
 pan3d/dataset_viewer.py                      | 44 ++++++++--
 pan3d/ui/bounds_configure.py                 | 89 +++++++++++++-------
 pan3d/ui/vue/src/components/PreviewBounds.js |  5 +-
 pan3d/utils.py                               |  4 +-
 4 files changed, 99 insertions(+), 43 deletions(-)

diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index dcd5588a..f60ad3fe 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -61,6 +61,7 @@ def __init__(
         self.current_event_loop = asyncio.get_event_loop()
         self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
         self._ui = None
+        self._default_style = CSS_FILE.read_text()
 
         self.plotter = geovista.GeoPlotter(off_screen=True, notebook=False)
         self.plotter.set_background("lightgrey")
@@ -120,7 +121,8 @@ def ui(self) -> VAppLayout:
             # Build UI
             self._ui = VAppLayout(self.server)
             with self._ui:
-                client.Style(CSS_FILE.read_text())
+                with client.Style(self._default_style) as style:
+                    self.ctrl.update_style = style.update
                 Toolbar(
                     self.apply_and_render,
                     self._submit_import,
@@ -147,6 +149,7 @@ def ui(self) -> VAppLayout:
                     ):
                         BoundsConfigure(
                             coordinate_change_bounds_function=self._coordinate_change_bounds,
+                            snap_camera_function=self._snap_camera_view_face,
                         )
                         RenderOptions()
                         with pyvista.trame.ui.plotter_ui(
@@ -291,6 +294,21 @@ def submit():
             submit, loading_state="ui_import_loading", unapplied_changes_state=None
         )
 
+    def _snap_camera_view_face(self):
+        face = self.state.cube_preview_face
+        if "X" in face:
+            viewUp = [0, 0, 1]
+            vector = [-1, -1, 1] if "+" in face else [1, 1, 1]
+            self.plotter.view_vector(vector, viewUp)
+        if "Y" in face:
+            viewUp = [0, 0, 1]
+            vector = [1, -1, 1] if "+" in face else [-1, 1, 1]
+            self.plotter.view_vector(vector, viewUp)
+        if "Z" in face:
+            viewUp = [0, 1, 0]
+            vector = [-1, 1, -1] if "+" in face else [-1, 1, 1]
+            self.plotter.view_vector(vector, viewUp)
+
     # -----------------------------------------------------
     # Rendering methods
     # -----------------------------------------------------
@@ -392,7 +410,7 @@ def plot_mesh(self) -> None:
         )
         if self.reset_camera:
             if len(self.builder.data_array.shape) > 2:
-                self.plotter.view_vector([1, 1, -1], [0, 1, 0])
+                self.plotter.view_vector([1, 1, 1], [0, 1, 0])
             elif not self.state.render_cartographic:
                 self.plotter.view_xy()
             self.reset_camera = False
@@ -511,7 +529,7 @@ def _data_array_changed(self) -> None:
                 coord_attrs.append({"key": "dtype", "value": str(dtype)})
                 coord_attrs.append({"key": "length", "value": int(size)})
                 coord_attrs.append({"key": "range", "value": coord_range})
-                bounds = [0, size]
+                bounds = [0, size - 1]
                 self.state.da_coordinates.append(
                     {
                         "name": key,
@@ -573,8 +591,12 @@ def _generate_preview(self) -> None:
             self.builder.dataset is None
             or self.builder.data_array_name is None
             or self.builder.slicing is None
+            or self._ui is None
         ):
             return
+        if not self.state.cube_view_mode:
+            self.ctrl.update_style(self._default_style)
+            return
         preview_slicing = {}
         if self.builder.t is not None and self.builder.t_index is not None:
             preview_slicing[self.builder.t] = self.builder.t_index
@@ -616,9 +638,14 @@ def _generate_preview(self) -> None:
                 preview_slicing[axis_name] = (
                     axis_slicing[0]
                     if "+" in self.state.cube_preview_face
-                    else axis_slicing[1] - 1
+                    else axis_slicing[1]
                 )
 
+            # update CSS to make blue slider thumb match preview outline
+            thumb_selector = f'.{axis_name}-slider .v-slider-thumb[aria-valuenow="{preview_slicing[axis_name]}"]'
+            thumb_style = thumb_selector + " { color: rgb(0, 100, 255) }"
+            self.ctrl.update_style(self._default_style + thumb_style)
+
         data = (
             self.builder.dataset[self.builder.data_array_name]
             .isel(preview_slicing)
@@ -628,7 +655,10 @@ def _generate_preview(self) -> None:
             lambda x, x_min, x_max: (x - x_min) / (x_max - x_min) * 255
         )(data, numpy.min(data), numpy.max(data)).astype(numpy.uint8)
         img = Image.fromarray(normalized_data)
-        img = img.rotate(180)  # match default axis orientation
+        # apply transposes to match rendering orientation
+        img = img.transpose(Image.FLIP_TOP_BOTTOM)
+        if "+" in self.state.cube_preview_face:
+            img = img.transpose(Image.FLIP_LEFT_RIGHT)
         buffer = BytesIO()
         img.save(buffer, format="PNG")
         encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
@@ -772,6 +802,6 @@ def _on_change_render_options(
             cartographic=render_cartographic,
         )
 
-    @change("cube_preview_face")
-    def _on_change_cube_preview_face(self, cube_preview_face, **kwargs):
+    @change("cube_view_mode", "cube_preview_face")
+    def _on_change_cube_view(self, cube_view_mode, cube_preview_face, **kwargs):
         self._generate_preview()
diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 7163a8cc..0631a386 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -8,6 +8,7 @@ class BoundsConfigure(vuetify.VMenu):
     def __init__(
         self,
         coordinate_change_bounds_function,
+        snap_camera_function,
         da_coordinates="da_coordinates",
         da_auto_slicing="da_auto_slicing",
         da_x="da_x",
@@ -45,19 +46,29 @@ def __init__(
                 )
             with vuetify.VCard(classes="pa-3", style="width: 325px"):
                 vuetify.VCardTitle("Configure Bounds")
+                vuetify.VCheckbox(
+                    v_model=(cube_view_mode,),
+                    label="Interactive Preview",
+                    hide_details=True,
+                )
                 vuetify.VSelect(
                     v_if=(cube_view_mode,),
                     v_model=(cube_preview_face,),
                     items=(cube_preview_face_options, []),
                     label="Face",
                     hide_details=True,
-                    style="float:right",
+                    style="float:left",
                 )
-                vuetify.VCheckbox(
-                    v_model=(cube_view_mode,), label="Cube View", hide_details=True
+                vuetify.VBtn(
+                    v_if=(cube_view_mode,),
+                    size="small",
+                    icon="mdi-video-marker",
+                    click=snap_camera_function,
+                    style="float:right",
                 )
                 with html.Div(v_if=(cube_view_mode,)):
                     PreviewBounds(
+                        v_if=(cube_preview,),
                         preview=(cube_preview,),
                         axes=(cube_preview_axes,),
                         coordinates=(da_coordinates,),
@@ -69,7 +80,7 @@ def __init__(
                 with html.Div(
                     v_for=(f"coord in {da_coordinates}",),
                 ):
-                    with vuetify.VRangeSlider(
+                    with html.Div(
                         v_if=(
                             f"""
                             ({da_x} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('X'))) ||
@@ -77,37 +88,51 @@ def __init__(
                             ({da_z} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('Z')))
                         """,
                         ),
-                        model_value=("coord.bounds",),
-                        label=("coord.name",),
-                        strict=True,
-                        hide_details=True,
-                        step=1,
-                        min=("coord.full_bounds[0]",),
-                        max=("coord.full_bounds[1]",),
-                        thumb_label=True,
-                        style="width: 250px",
-                        end=(coordinate_change_bounds_function, "[coord.name, $event]"),
-                        __events=[("end", "end")],
+                        style="text-transform: capitalize",
                     ):
-                        with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
-                            html.Span(
-                                ("{{ coord.labels[modelValue] }}",),
-                                style="white-space: nowrap",
-                            )
+                        html.Span("{{ coord.name.replaceAll('_', ' ') }}")
+                        with vuetify.VRangeSlider(
+                            model_value=("coord.bounds",),
+                            strict=True,
+                            hide_details=True,
+                            step=1,
+                            min=("coord.full_bounds[0]",),
+                            max=("coord.full_bounds[1]",),
+                            thumb_label=True,
+                            classes=("coord.name +'-slider px-3'",),
+                            end=(
+                                coordinate_change_bounds_function,
+                                "[coord.name, $event]",
+                            ),
+                            __events=[("end", "end")],
+                        ):
+                            with vuetify.Template(
+                                v_slot_thumb_label=("{ modelValue }",)
+                            ):
+                                html.Span(
+                                    ("{{ coord.labels[modelValue] }}",),
+                                    style="white-space: nowrap",
+                                )
                 with html.Div(
                     v_for=(f"coord in {da_coordinates}",),
                 ):
-                    with vuetify.VSlider(
-                        v_model=(da_t_index),
+                    with html.Div(
                         v_if=(f"{da_t} === coord.name",),
-                        label=("coord.name",),
-                        min=("coord.full_bounds[0]",),
-                        max=("coord.full_bounds[1] - 1",),
-                        step=1,
-                        thumb_label=True,
+                        style="text-transform: capitalize",
                     ):
-                        with vuetify.Template(v_slot_thumb_label=("{ modelValue }",)):
-                            html.Span(
-                                ("{{ coord.labels[modelValue] }}",),
-                                style="white-space: nowrap",
-                            )
+                        html.Span("{{ coord.name }}")
+                        with vuetify.VSlider(
+                            v_model=(da_t_index),
+                            min=("coord.full_bounds[0]",),
+                            max=("coord.full_bounds[1] - 1",),
+                            step=1,
+                            thumb_label=True,
+                            classes="px-3",
+                        ):
+                            with vuetify.Template(
+                                v_slot_thumb_label=("{ modelValue }",)
+                            ):
+                                html.Span(
+                                    ("{{ coord.labels[modelValue] }}",),
+                                    style="white-space: nowrap",
+                                )
diff --git a/pan3d/ui/vue/src/components/PreviewBounds.js b/pan3d/ui/vue/src/components/PreviewBounds.js
index 6e5e397b..42d65de4 100644
--- a/pan3d/ui/vue/src/components/PreviewBounds.js
+++ b/pan3d/ui/vue/src/components/PreviewBounds.js
@@ -190,7 +190,8 @@ export default {
       onMouseMove,
       onMousePress,
       onMouseRelease,
-      color: "rgb(255,0,0)",
+      color: "rgb(255, 0, 0)",
+      outline: "4px solid rgb(0, 100, 255)",
       radius: 7,
     };
   },
@@ -202,7 +203,7 @@ export default {
     @mousemove="onMouseMove"
     @mouseup="onMouseRelease"
     @mouseleave="onMouseRelease"
-    style="cursor: pointer;"
+    :style="'cursor: pointer; outline:'+outline"
   >
     <image
       ref="previewImage"
diff --git a/pan3d/utils.py b/pan3d/utils.py
index 85660702..80e8daeb 100644
--- a/pan3d/utils.py
+++ b/pan3d/utils.py
@@ -65,9 +65,9 @@ def has_gpu_rendering():
     "da_t_index": 0,
     "da_size": None,
     "da_auto_slicing": True,
-    "cube_view_mode": True,
+    "cube_view_mode": False,
     "cube_preview": None,
-    "cube_preview_face": "+Z",
+    "cube_preview_face": "-Z",
     "cube_preview_face_options": [],
     "cube_preview_axes": None,
     "ui_loading": False,

From 32476117eebe3de18a9e7daecaac58632af1b374 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Fri, 7 Jun 2024 14:58:56 +0000
Subject: [PATCH 13/23] fix: add new folders to packages list in pyproject.toml

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 05c189d8..cbf0db3d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.vue", "pan3d.ui.vue.module", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From 7aa8ea9977829c1df43fce06c586b67cdb4e7ad3 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 16:46:32 +0000
Subject: [PATCH 14/23] refactor: move javascript code to its own top-level
 directory

---
 {pan3d/ui/vue => pan3d-js}/.eslintrc.cjs                   | 0
 {pan3d/ui/vue => pan3d-js}/.gitignore                      | 0
 {pan3d/ui/vue => pan3d-js}/module/__init__.py              | 0
 {pan3d/ui/vue => pan3d-js}/package.json                    | 0
 {pan3d/ui/vue => pan3d-js}/src/components/PreviewBounds.js | 0
 {pan3d/ui/vue => pan3d-js}/src/components/index.js         | 0
 {pan3d/ui/vue => pan3d-js}/src/main.js                     | 0
 {pan3d/ui/vue => pan3d-js}/vite.config.js                  | 0
 pyproject.toml                                             | 2 +-
 9 files changed, 1 insertion(+), 1 deletion(-)
 rename {pan3d/ui/vue => pan3d-js}/.eslintrc.cjs (100%)
 rename {pan3d/ui/vue => pan3d-js}/.gitignore (100%)
 rename {pan3d/ui/vue => pan3d-js}/module/__init__.py (100%)
 rename {pan3d/ui/vue => pan3d-js}/package.json (100%)
 rename {pan3d/ui/vue => pan3d-js}/src/components/PreviewBounds.js (100%)
 rename {pan3d/ui/vue => pan3d-js}/src/components/index.js (100%)
 rename {pan3d/ui/vue => pan3d-js}/src/main.js (100%)
 rename {pan3d/ui/vue => pan3d-js}/vite.config.js (100%)

diff --git a/pan3d/ui/vue/.eslintrc.cjs b/pan3d-js/.eslintrc.cjs
similarity index 100%
rename from pan3d/ui/vue/.eslintrc.cjs
rename to pan3d-js/.eslintrc.cjs
diff --git a/pan3d/ui/vue/.gitignore b/pan3d-js/.gitignore
similarity index 100%
rename from pan3d/ui/vue/.gitignore
rename to pan3d-js/.gitignore
diff --git a/pan3d/ui/vue/module/__init__.py b/pan3d-js/module/__init__.py
similarity index 100%
rename from pan3d/ui/vue/module/__init__.py
rename to pan3d-js/module/__init__.py
diff --git a/pan3d/ui/vue/package.json b/pan3d-js/package.json
similarity index 100%
rename from pan3d/ui/vue/package.json
rename to pan3d-js/package.json
diff --git a/pan3d/ui/vue/src/components/PreviewBounds.js b/pan3d-js/src/components/PreviewBounds.js
similarity index 100%
rename from pan3d/ui/vue/src/components/PreviewBounds.js
rename to pan3d-js/src/components/PreviewBounds.js
diff --git a/pan3d/ui/vue/src/components/index.js b/pan3d-js/src/components/index.js
similarity index 100%
rename from pan3d/ui/vue/src/components/index.js
rename to pan3d-js/src/components/index.js
diff --git a/pan3d/ui/vue/src/main.js b/pan3d-js/src/main.js
similarity index 100%
rename from pan3d/ui/vue/src/main.js
rename to pan3d-js/src/main.js
diff --git a/pan3d/ui/vue/vite.config.js b/pan3d-js/vite.config.js
similarity index 100%
rename from pan3d/ui/vue/vite.config.js
rename to pan3d-js/vite.config.js
diff --git a/pyproject.toml b/pyproject.toml
index cbf0db3d..64b0ed1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.ui.vue", "pan3d.ui.vue.module", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.vue", "pan3d-js.serve", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From 743232879e4f4dab9fa9ffa4b19d2031c0d78889 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 16:49:03 +0000
Subject: [PATCH 15/23] fix: update javscript path in CI for build step

---
 .github/workflows/test.yml | 69 +++++++++++++++++---------------------
 1 file changed, 30 insertions(+), 39 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 60456256..1adb03dc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,51 +29,42 @@ jobs:
       matrix:
         python-version: [3.9]
         config:
-        - {
-            name: "Linux",
-            os: ubuntu-latest
-          }
-        - {
-            name: "MacOSX",
-            os: macos-latest
-          }
-        - {
-            name: "Windows",
-            os: windows-latest
-          }
+          - { name: "Linux", os: ubuntu-latest }
+          - { name: "MacOSX", os: macos-latest }
+          - { name: "Windows", os: windows-latest }
 
     defaults:
       run:
         shell: bash
 
     steps:
-    - name: Checkout
-      uses: actions/checkout@v2
+      - name: Checkout
+        uses: actions/checkout@v2
 
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
-      with:
-        python-version: ${{ matrix.python-version }}
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
 
-    - name: Set up NPM
-      uses: actions/setup-node@v4
-      with:
-        node-version: 18
-    - name: Install Vue component dependencies
-      run: npm install
-      working-directory: pan3d/ui/vue
-    - name: Lint Vue components
-      run: npm run lint
-      working-directory: pan3d/ui/vue
-    - name: Build Vue components
-      run: npm run build
-      working-directory: pan3d/ui/vue
+      - name: Set up NPM
+        uses: actions/setup-node@v4
+        with:
+          node-version: 18
+      - name: Install Vue component dependencies
+        run: npm install
+        working-directory: pan3d-js
+      - name: Lint Vue components
+        run: npm run lint
+        working-directory: pan3d-js
+      - name: Build Vue components
+        run: npm run build
+        working-directory: pan3d-js
 
-    - name: Install and Run Tests
-      run: |
-        pip install .
-        pip install -r tests/requirements.txt
-        pytest -s ./tests/test_builder.py
-        pip install .[viewer]
-        pytest -s ./tests/test_viewer.py
-      working-directory: .
+      - name: Install and Run Tests
+        run: |
+          pip install .
+          pip install -r tests/requirements.txt
+          pytest -s ./tests/test_builder.py
+          pip install .[viewer]
+          pytest -s ./tests/test_viewer.py
+        working-directory: .

From 73d24a45b0485502819862ba8a2f779595ef2f7f Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 17:08:41 +0000
Subject: [PATCH 16/23] refactor: move module and serve dirs back into python
 package

---
 .gitignore                                                 | 1 +
 pan3d-js/vite.config.js                                    | 2 +-
 pan3d/dataset_viewer.py                                    | 2 +-
 {pan3d-js => pan3d/ui/pan3d_components}/module/__init__.py | 0
 pyproject.toml                                             | 2 +-
 5 files changed, 4 insertions(+), 3 deletions(-)
 rename {pan3d-js => pan3d/ui/pan3d_components}/module/__init__.py (100%)

diff --git a/.gitignore b/.gitignore
index bbee3317..28a57cc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ node_modules
 
 examples/jupyter/*.gif
 site/*
+pan3d/ui/pan3d_components/serve
 
 # local env files
 .env.local
diff --git a/pan3d-js/vite.config.js b/pan3d-js/vite.config.js
index fe2f1211..b7df32ba 100644
--- a/pan3d-js/vite.config.js
+++ b/pan3d-js/vite.config.js
@@ -15,7 +15,7 @@ export default {
         },
       },
     },
-    outDir: "serve",
+    outDir: "../pan3d/ui/pan3d_components/serve",
     assetsDir: ".",
   },
 };
diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index f60ad3fe..59d1e19d 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -23,7 +23,7 @@
 from pan3d import catalogs as pan3d_catalogs
 from pan3d.dataset_builder import DatasetBuilder
 from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions, BoundsConfigure
-from pan3d.ui.vue import module
+from pan3d.ui.pan3d_components import module
 from pan3d.utils import (
     initial_state,
     has_gpu_rendering,
diff --git a/pan3d-js/module/__init__.py b/pan3d/ui/pan3d_components/module/__init__.py
similarity index 100%
rename from pan3d-js/module/__init__.py
rename to pan3d/ui/pan3d_components/module/__init__.py
diff --git a/pyproject.toml b/pyproject.toml
index 64b0ed1a..99d740dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.ui.vue", "pan3d-js.serve", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.serve", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From c9b449ff6a54a8f50818b49d1ea15e0bab2ba22b Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 17:20:42 +0000
Subject: [PATCH 17/23] fix: include module and serve packages individusaly

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 99d740dc..d3eedccf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.serve", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.module", "pan3d.ui.pan3d_components.serve", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From c891b387ed64989eaf1dda385f5cb3f26991d3c2 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 17:58:36 +0000
Subject: [PATCH 18/23] refactor: apply suggestions

---
 pan3d/ui/bounds_configure.py                 | 2 +-
 pan3d/ui/pan3d_components/module/__init__.py | 2 +-
 pan3d/ui/{preview_bounds.py => widgets.py}   | 0
 3 files changed, 2 insertions(+), 2 deletions(-)
 rename pan3d/ui/{preview_bounds.py => widgets.py} (100%)

diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 0631a386..0925c0ad 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -1,7 +1,7 @@
 from trame.widgets import html
 from trame.widgets import vuetify3 as vuetify
 
-from .preview_bounds import PreviewBounds
+from .widgets import PreviewBounds
 
 
 class BoundsConfigure(vuetify.VMenu):
diff --git a/pan3d/ui/pan3d_components/module/__init__.py b/pan3d/ui/pan3d_components/module/__init__.py
index 1c3fdd17..c8c44572 100644
--- a/pan3d/ui/pan3d_components/module/__init__.py
+++ b/pan3d/ui/pan3d_components/module/__init__.py
@@ -1,7 +1,7 @@
 from pathlib import Path
 
 # Compute local path to serve
-serve_path = str(Path(__file__).parent.with_name("serve").resolve())
+serve_path = str(Path('pan3d/ui/pan3d_components/module'))
 
 # Serve directory for JS/CSS files
 serve = {"__pan3d_components": serve_path}
diff --git a/pan3d/ui/preview_bounds.py b/pan3d/ui/widgets.py
similarity index 100%
rename from pan3d/ui/preview_bounds.py
rename to pan3d/ui/widgets.py

From bf128ba380135ed2b2a90b34fea5288843b799a4 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 17:59:42 +0000
Subject: [PATCH 19/23] style: Use double quotes

---
 pan3d/ui/pan3d_components/module/__init__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pan3d/ui/pan3d_components/module/__init__.py b/pan3d/ui/pan3d_components/module/__init__.py
index c8c44572..8370ddeb 100644
--- a/pan3d/ui/pan3d_components/module/__init__.py
+++ b/pan3d/ui/pan3d_components/module/__init__.py
@@ -1,7 +1,7 @@
 from pathlib import Path
 
 # Compute local path to serve
-serve_path = str(Path('pan3d/ui/pan3d_components/module'))
+serve_path = str(Path("pan3d/ui/pan3d_components/module"))
 
 # Serve directory for JS/CSS files
 serve = {"__pan3d_components": serve_path}

From 06bc8bd0840bba4f662dbd1d16ac0d9180d69190 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 18:07:50 +0000
Subject: [PATCH 20/23] refactor: move `serve` directory within `module`
 directory

---
 .gitignore                                   | 2 +-
 pan3d-js/vite.config.js                      | 2 +-
 pan3d/ui/pan3d_components/module/__init__.py | 2 +-
 pyproject.toml                               | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index 28a57cc5..20159639 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,7 @@ node_modules
 
 examples/jupyter/*.gif
 site/*
-pan3d/ui/pan3d_components/serve
+pan3d/ui/pan3d_components/module/serve
 
 # local env files
 .env.local
diff --git a/pan3d-js/vite.config.js b/pan3d-js/vite.config.js
index b7df32ba..b3aa06b9 100644
--- a/pan3d-js/vite.config.js
+++ b/pan3d-js/vite.config.js
@@ -15,7 +15,7 @@ export default {
         },
       },
     },
-    outDir: "../pan3d/ui/pan3d_components/serve",
+    outDir: "../pan3d/ui/pan3d_components/module/serve",
     assetsDir: ".",
   },
 };
diff --git a/pan3d/ui/pan3d_components/module/__init__.py b/pan3d/ui/pan3d_components/module/__init__.py
index 8370ddeb..01ea56bc 100644
--- a/pan3d/ui/pan3d_components/module/__init__.py
+++ b/pan3d/ui/pan3d_components/module/__init__.py
@@ -1,7 +1,7 @@
 from pathlib import Path
 
 # Compute local path to serve
-serve_path = str(Path("pan3d/ui/pan3d_components/module"))
+serve_path = str(Path(__file__).with_name("serve").resolve())
 
 # Serve directory for JS/CSS files
 serve = {"__pan3d_components": serve_path}
diff --git a/pyproject.toml b/pyproject.toml
index d3eedccf..2cb141c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.module", "pan3d.ui.pan3d_components.serve", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.module", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From a28e3083212c6c2dd0556228951f85d9f62a15d9 Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 18:51:35 +0000
Subject: [PATCH 21/23] refactor: move widgets.py into pan3d_components

---
 pan3d/ui/bounds_configure.py               | 2 +-
 pan3d/ui/{ => pan3d_components}/widgets.py | 0
 2 files changed, 1 insertion(+), 1 deletion(-)
 rename pan3d/ui/{ => pan3d_components}/widgets.py (100%)

diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py
index 0925c0ad..238d748b 100644
--- a/pan3d/ui/bounds_configure.py
+++ b/pan3d/ui/bounds_configure.py
@@ -1,7 +1,7 @@
 from trame.widgets import html
 from trame.widgets import vuetify3 as vuetify
 
-from .widgets import PreviewBounds
+from .pan3d_components.widgets import PreviewBounds
 
 
 class BoundsConfigure(vuetify.VMenu):
diff --git a/pan3d/ui/widgets.py b/pan3d/ui/pan3d_components/widgets.py
similarity index 100%
rename from pan3d/ui/widgets.py
rename to pan3d/ui/pan3d_components/widgets.py

From 8389496ac09e0b4988c4d733fd6ac0c91f34d3bf Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 18:54:27 +0000
Subject: [PATCH 22/23] fix: Add "pan3d.ui.pan3d_components" to packages list

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 2cb141c9..909d9396 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ requires = ["setuptools>=61", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools]
-packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components.module", "pan3d.catalogs"]
+packages = ["pan3d", "pan3d.ui", "pan3d.ui.pan3d_components", "pan3d.ui.pan3d_components.module", "pan3d.catalogs"]
 
 [tool.semantic_release]
 version_variables = ["setup.py:__version__"]

From 1c4d30cbab102623c7c4be0aaf73ef4fe768da1f Mon Sep 17 00:00:00 2001
From: Anne Haley <anne.haley@kitware.com>
Date: Tue, 18 Jun 2024 20:35:09 +0000
Subject: [PATCH 23/23] refactor: Enable module in widgets, not dataset_viewer

---
 pan3d/dataset_viewer.py              |  2 --
 pan3d/ui/pan3d_components/widgets.py | 14 +++++++++++++-
 2 files changed, 13 insertions(+), 3 deletions(-)

diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py
index 59d1e19d..092bdc44 100644
--- a/pan3d/dataset_viewer.py
+++ b/pan3d/dataset_viewer.py
@@ -23,7 +23,6 @@
 from pan3d import catalogs as pan3d_catalogs
 from pan3d.dataset_builder import DatasetBuilder
 from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions, BoundsConfigure
-from pan3d.ui.pan3d_components import module
 from pan3d.utils import (
     initial_state,
     has_gpu_rendering,
@@ -57,7 +56,6 @@ def __init__(
             builder._viewer = self
         self.builder = builder
         self.server = get_server(server, client_type="vue3")
-        self.server.enable_module(module)
         self.current_event_loop = asyncio.get_event_loop()
         self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
         self._ui = None
diff --git a/pan3d/ui/pan3d_components/widgets.py b/pan3d/ui/pan3d_components/widgets.py
index b99f9d6a..a95a0740 100644
--- a/pan3d/ui/pan3d_components/widgets.py
+++ b/pan3d/ui/pan3d_components/widgets.py
@@ -1,4 +1,16 @@
-from trame_client.widgets.core import HtmlElement
+from trame_client.widgets.core import AbstractElement
+from . import module
+
+__all__ = [
+    "PreviewBounds",
+]
+
+
+class HtmlElement(AbstractElement):
+    def __init__(self, _elem_name, children=None, **kwargs):
+        super().__init__(_elem_name, children, **kwargs)
+        if self.server:
+            self.server.enable_module(module)
 
 
 class PreviewBounds(HtmlElement):