diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d30a7ee..e365418 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Miniconda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v4 with: auto-update-conda: true activate-environment: quickview diff --git a/pyproject.toml b/pyproject.toml index 7532f0e..93f63a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ requires-python = ">=3.10" dependencies = [ "trame>=3.12", "trame-vuetify>=3.1", - "trame-rca[turbo]>=2.4", + "trame-rca[turbo]>=2.5.1", "pyproj>=3.6.1", "netCDF4>=1.6.5", "trame-dataclass >=2.0.2", diff --git a/src/e3sm_quickview/module/serve/style.css b/src/e3sm_quickview/module/serve/style.css index eff7223..36ccd19 100644 --- a/src/e3sm_quickview/module/serve/style.css +++ b/src/e3sm_quickview/module/serve/style.css @@ -18,3 +18,7 @@ .user-select-none { user-select: none; } + +.tooltip-no-padding.v-tooltip > .v-overlay__content { + padding: 0; +} diff --git a/src/e3sm_quickview/utils/debounce.py b/src/e3sm_quickview/utils/debounce.py new file mode 100644 index 0000000..62204c4 --- /dev/null +++ b/src/e3sm_quickview/utils/debounce.py @@ -0,0 +1,34 @@ +import asyncio +import inspect +from functools import wraps + + +def debounce(wait_time): + """ + Decorator for debouncing functions in async code. + """ + + def decorator(fn): + task = None + + @wraps(fn) + def debounced(*args, **kwargs): + nonlocal task + + # Cancel the pending execution task + if task is not None: + task.cancel() + + # Define an internal wrapper to handle the sleep and execution + async def delayed_execution(): + await asyncio.sleep(wait_time) + result = fn(*args, **kwargs) + if inspect.isawaitable(result): + await result + + # Schedule the execution non-blockingly + task = asyncio.create_task(delayed_execution()) + + return debounced + + return decorator diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index 7674be2..c4499a4 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -14,11 +14,12 @@ from trame.widgets import vuetify3 as v3 from vtkmodules.vtkRenderingCore import ( vtkCamera, + vtkCellPicker, vtkRenderWindow, vtkRenderWindowInteractor, ) -from e3sm_quickview.utils import perf +from e3sm_quickview.utils import debounce, perf from e3sm_quickview.view_panel import VariableView @@ -57,6 +58,7 @@ def __init__(self, server, source): self._camera = vtkCamera(parallel_projection=1) self._render_window = vtkRenderWindow() self._render_window.OffScreenRenderingOn() + self._picker = vtkCellPicker(tolerance=0.0005) # Perf: time the actual VTK render on the shared render window. # Emits `view.shared.render_window` with the elapsed time for @@ -91,6 +93,7 @@ def __init__(self, server, source): interactor_style=self._style ) self._render_window_interactor.SetRenderWindow(self._render_window) + self._render_window_interactor.AddObserver("ModifiedEvent", self._on_hover) self.loop = asyncio.get_event_loop() self.layout_dirty = True @@ -105,6 +108,10 @@ def __init__(self, server, source): rca.initialize(self.server) colormaps.initialize(self.server) + # Initialize state for picking + self.state.hover_info = None + self.state.hover_tooltip = None + def _on_render_start(self, *_): if perf.is_enabled(): self._render_t0 = time.perf_counter() @@ -115,6 +122,34 @@ def _on_render_end(self, *_): perf.log("view.shared.render_window", dt_ms) self._render_t0 = None + @debounce.debounce(0.2) + def _on_hover(self, *_): + with self.state: + if not self.state.hover_info: + self.state.hover_tooltip = None + return + + x, y = self._render_window_interactor.GetEventPosition() + renderer = self.get_view(self.state.hover_info, None).renderer + self._picker.Pick(x, y, 0, renderer) + if self._picker.cell_id < 0: + self.state.hover_tooltip = None + return + + # world_position = self._picker.pick_position + cell_id = self._picker.cell_id + data_info = {} # {"cell_id": cell_id, "xyz": world_position} + ds = self._picker.GetDataSet() + if ds: + cell_data = ds.cell_data + n_arrays = cell_data.number_of_arrays + + for i in range(n_arrays): + array = cell_data.GetArray(i) + data_info[array.name] = array.GetTuple(cell_id) + + self.state.hover_tooltip = data_info + def refresh_ui(self, **_): for view in self._var2view.values(): view._build_ui() diff --git a/src/e3sm_quickview/view_panel.py b/src/e3sm_quickview/view_panel.py index e588981..6ec00e2 100644 --- a/src/e3sm_quickview/view_panel.py +++ b/src/e3sm_quickview/view_panel.py @@ -115,6 +115,8 @@ def _build_ui(self): ), tile=("active_layout !== 'auto_layout'",), raw_attrs=[f'data-field-name="{self.variable_name}"'], + v_on_mouseenter=f"hover_info = '{self.variable_name}'", + v_on_mouseleave="hover_info = null", ): with v3.VRow( dense=True, @@ -175,22 +177,37 @@ def _build_ui(self): classes="text-caption px-1 text-no-wrap", ) - with html.Div( - style=( - """ - { + with v3.VTooltip(classes="tooltip-no-padding"): + with v3.Template(v_slot_activator="{props}"): + with html.Div( + v_bind="props", + style=( + f""" + {{ aspectRatio: active_layout === 'auto_layout' ? (1.0 / aspect_ratio) : null, height: active_layout !== 'auto_layout' ? 'calc(100% - 2.4rem)' : null, - pointerEvents: 'none', - } + pointerEvents: hover_info === '{self.variable_name}' ? 'all' : 'none', + }} """, - ), - ): - rca.ImageRegion( - enable_interaction=False, - bounds=(self._bounds_key, (0, 0, 1, 1)), - size=(self.update_size, "[$event]"), - ) + ), + ): + rca.ImageRegion( + enable_interaction=False, + bounds=(self._bounds_key, (0, 0, 1, 1)), + size=(self.update_size, "[$event]"), + send_mouse_move=( + f"hover_info === '{self.variable_name}'", + ), + ) + + with v3.VTable(density="compact", theme="dark", striped="even"): + with html.Tbody(): + with html.Tr( + v_for="v, k in hover_tooltip || {}", + key="k", + ): + html.Td("{{k}}") + html.Td("{{v[0]}}") with self.colormap.provide_as(self.name): colormaps.HorizontalScalarBar(self.name, popup_location="top")