diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a034b1f..93115b7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.1 +current_version = 1.0.2 commit = True tag = True diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 7c5bbd7..c821cec 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -155,6 +155,11 @@ jobs: - **Apple Silicon (M1/M2/M3)**: Download the \`aarch64\` version - **Intel Macs**: Download the \`x64\` version + Reminder: After download, use the following command in Terminal to unblock the app for macOS + ``` + xattr -d com.apple.quarantine .dmg + ``` + ## Support If you encounter any issues: diff --git a/docs/setup/for_app_developers.md b/docs/setup/for_app_developers.md index 253aafb..5433f53 100644 --- a/docs/setup/for_app_developers.md +++ b/docs/setup/for_app_developers.md @@ -28,16 +28,6 @@ conda activate quickview pip install -e . ``` ----- -## Additional requirements - -Additional requirements for the app are satisfied once the app is launched -for the very first time using Python virtual environments `venv`. An additional -step for the use is to provide the path to ParaView's Python client that is -distributed with the ParaView binaries. The `pvpython` binary is present in the -`bin` directory of ParaView, on macOS the path is something like -`/Applications/ParaView-5.13.0.app/Contents/bin/pvpython` - ---- ## Launch the app from command line diff --git a/pyproject.toml b/pyproject.toml index 10b900b..49286db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quickview" -version = "1.0.1" +version = "1.0.2" description = "An application to explore/analyze data for atmosphere component for E3SM" authors = [ {name = "Kitware Inc."}, diff --git a/quickview/__init__.py b/quickview/__init__.py index 7ce5c4b..5fc3e2e 100644 --- a/quickview/__init__.py +++ b/quickview/__init__.py @@ -1,5 +1,5 @@ """QuickView: Visual Analysis for E3SM Atmosphere Data.""" -__version__ = "1.0.1" +__version__ = "1.0.2" __author__ = "Kitware Inc." __license__ = "Apache-2.0" diff --git a/quickview/interface.py b/quickview/interface.py index 6b6f383..76005c0 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -11,9 +11,8 @@ from trame.decorators import life_cycle, trigger, change from trame.ui.vuetify import SinglePageWithDrawerLayout -from trame.widgets import vuetify as v2, html, client +from trame.widgets import vuetify as v2, client from trame.widgets import paraview as pvWidgets -from trame.widgets import grid from trame_server.core import Server @@ -22,8 +21,8 @@ from quickview.ui.slice_selection import SliceSelection from quickview.ui.projection_selection import ProjectionSelection from quickview.ui.variable_selection import VariableSelection -from quickview.ui.view_settings import ViewProperties from quickview.ui.toolbar import Toolbar +from quickview.ui.grid import Grid # Build color cache here from quickview.view_manager import build_color_information @@ -33,6 +32,12 @@ from paraview.modules import vtkRemotingCore as rc +try: + from trame.widgets import tauri +except ImportError: + # Fallback if tauri is not available + tauri = None + rc.vtkProcessModule.GetProcessModule().UpdateProcessType( rc.vtkProcessModule.PROCESS_BATCH, 0 ) @@ -162,6 +167,7 @@ def __init__( self.viewmanager = ViewManager(source, self.server, self.state) state = self.state + state.tauri_avail = False # Load state variables from the source object state.data_file = source.data_file if source.data_file else "" state.conn_file = source.conn_file if source.conn_file else "" @@ -537,21 +543,6 @@ def load_variables(self, use_cached_layout=False): "h": item.get("h", 3), } - def update_colormap(self, index, value): - """Update the colormap for a variable.""" - self.viewmanager.update_colormap(index, value) - - def update_log_scale(self, index, value): - """Update the log scale setting for a variable.""" - self.viewmanager.update_log_scale(index, value) - - def update_invert_colors(self, index, value): - """Update the color inversion setting for a variable.""" - self.viewmanager.update_invert_colors(index, value) - - def update_scalar_bars(self, event): - self.viewmanager.update_scalar_bars(event) - def update_available_color_maps(self): with self.state as state: # Directly use the toggle states to determine which colormaps to show @@ -566,16 +557,6 @@ def update_available_color_maps(self): state.colormaps = noncvd state.colormaps.sort(key=lambda x: x["text"]) - def set_manual_color_range(self, index, type, value): - # Get current values from state to handle min/max independently - min_val = self.state.varmin[index] if type.lower() == "max" else value - max_val = self.state.varmax[index] if type.lower() == "min" else value - # Delegate to view manager which will update both the view and sync state - self.viewmanager.set_manual_color_range(index, min_val, max_val) - - def revert_to_auto_color_range(self, index): - self.viewmanager.revert_to_auto_color_range(index) - def zoom(self, type): if type.lower() == "in": self.viewmanager.zoom_in() @@ -749,6 +730,22 @@ def ui(self) -> SinglePageWithDrawerLayout: with self._ui as layout: layout.footer.clear() layout.title.clear() + + # Initialize Tauri if available + if tauri: + tauri.initialize(self.server) + with tauri.Dialog() as dialog: + self.ctrl.open = dialog.open + self.ctrl.save = dialog.save + else: + # Fallback for non-tauri environments + self.ctrl.open = lambda title: None + self.ctrl.save = lambda title: None + + client.ClientTriggers( + mounted="tauri_avail = window.__TAURI__ !== undefined;" + ) + """ with html.Div( style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4px 8px;", @@ -825,180 +822,9 @@ def ui(self) -> SinglePageWithDrawerLayout: ) with layout.content: - with grid.GridLayout( - layout=("layout",), - col_num=12, - row_height=100, - is_draggable=True, - is_resizable=True, - vertical_compact=True, - layout_updated="layout = $event; trigger('layout_changed', [$event])", - ) as self.grid: - with grid.GridItem( - v_for="vref, idx in views", - key="vref", - v_bind=("layout[idx]",), - style="transition-property: none;", - ): - with v2.VCard( - classes="fill-height", style="overflow: hidden;" - ): - with v2.VCardText( - style="height: calc(100% - 0.66rem); position: relative;", - classes="pa-0", - ) as cardcontent: - # VTK View fills entire space - cardcontent.add_child( - """ - - - """, - ) - client.ClientTriggers( - beforeDestroy="trigger('view_gc', [vref])", - # mounted=(self.viewmanager.reset_specific_view, '''[idx, - # {width: $refs[vref].vtkContainer.getBoundingClientRect().width, - # height: $refs[vref].vtkContainer.getBoundingClientRect().height}] - # ''') - ) - # Mask to prevent VTK view from getting scroll/mouse events - html.Div( - style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;" - ) - # Top-left info: time, level, variable name and average - with html.Div( - style="position: absolute; top: 8px; left: 8px; padding: 4px 8px; background-color: rgba(255, 255, 255, 0.1); color: white; font-size: 0.875rem; border-radius: 4px; z-index: 2;", - classes="drag-ignore font-monospace", - ): - # Variable name - html.Div( - "{{ variables[idx] }}", - style="color: white;", - classes="font-weight-medium", - ) - # Average value - html.Div( - ( - "(avg: {{ " - "varaverage[idx] !== null && varaverage[idx] !== undefined && !isNaN(varaverage[idx]) && typeof varaverage[idx] === 'number' ? " - "varaverage[idx].toExponential(2) : " - "'N/A' " - "}})" - ), - style="color: white;", - classes="font-weight-medium", - ) - # Show time - html.Div( - "t = {{ tstamp }}", - style="color: white;", - classes="font-weight-medium", - ) - # Show level for midpoint variables - html.Div( - v_if="midpoint_vars.includes(variables[idx])", - children="k = {{ midpoint }}", - style="color: white;", - classes="font-weight-medium", - ) - # Show level for interface variables - html.Div( - v_if="interface_vars.includes(variables[idx])", - children="k = {{ interface }}", - style="color: white;", - classes="font-weight-medium", - ) - # Colorbar container (horizontal layout at bottom) - with html.Div( - style="position: absolute; bottom: 8px; left: 8px; right: 8px; display: flex; align-items: center; justify-content: center; padding: 4px 8px 4px 8px; background-color: rgba(255, 255, 255, 0.1); height: 28px; z-index: 3; overflow: visible; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);", - classes="drag-ignore", - ): - # View Properties button (small icon) - ViewProperties( - update_colormap=self.update_colormap, - update_log_scale=self.update_log_scale, - update_invert=self.update_invert_colors, - update_range=self.set_manual_color_range, - reset=self.revert_to_auto_color_range, - style="margin-right: 8px; display: flex; align-items: center;", - ) - # Color min value - html.Span( - ( - "{{ " - "varmin[idx] !== null && varmin[idx] !== undefined && !isNaN(varmin[idx]) && typeof varmin[idx] === 'number' ? (" - "uselogscale[idx] && varmin[idx] > 0 ? " - "'10^(' + Math.log10(varmin[idx]).toFixed(1) + ')' : " - "varmin[idx].toExponential(1)" - ") : 'Auto' " - "}}" - ), - style="color: white;", - classes="font-weight-medium", - ) - # Colorbar - with html.Div( - style="flex: 1; display: flex; align-items: center; margin: 0 8px; height: 0.6rem; position: relative;", - classes="drag-ignore", - ): - # Colorbar image - html.Img( - src=( - "colorbar_images[idx] || 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='", - None, - ), - style="height: 100%; width: 100%; object-fit: fill;", - classes="rounded-lg border-thin", - v_on=( - "{" - "mousemove: (e) => { " - "const rect = e.target.getBoundingClientRect(); " - "const x = e.clientX - rect.left; " - "const width = rect.width; " - "const fraction = Math.max(0, Math.min(1, x / width)); " - "probe_location = [x, width, fraction, idx]; " - "}, " - "mouseenter: () => { probe_enabled = true; }, " - "mouseleave: () => { probe_enabled = false; probe_location = null; } " - "}" - ), - ) - # Probe tooltip (pan3d style - as sibling to colorbar) - html.Div( - v_if="probe_enabled && probe_location && probe_location[3] === idx", - v_bind_style="{position: 'absolute', bottom: '100%', left: probe_location[0] + 'px', transform: 'translateX(-50%)', marginBottom: '0.25rem', backgroundColor: '#000000', color: '#ffffff', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.875rem', whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 1000, fontFamily: 'monospace', boxShadow: '0 2px 4px rgba(0,0,0,0.3)'}", - children=( - "{{ " - "probe_location && varmin[idx] !== null && varmax[idx] !== null ? (" - "uselogscale[idx] && varmin[idx] > 0 && varmax[idx] > 0 ? " - "'10^(' + (" - "Math.log10(varmin[idx]) + " - "(Math.log10(varmax[idx]) - Math.log10(varmin[idx])) * probe_location[2]" - ").toFixed(2) + ')' : " - "((varmin[idx] || 0) + ((varmax[idx] || 1) - (varmin[idx] || 0)) * probe_location[2]).toExponential(3)" - ") : '' " - "}}" - ), - ) - # Color max value - html.Span( - ( - "{{ " - "varmax[idx] !== null && varmax[idx] !== undefined && !isNaN(varmax[idx]) && typeof varmax[idx] === 'number' ? (" - "uselogscale[idx] && varmax[idx] > 0 ? " - "'10^(' + Math.log10(varmax[idx]).toFixed(1) + ')' : " - "varmax[idx].toExponential(1)" - ") : 'Auto' " - "}}" - ), - style="color: white;", - classes="font-weight-medium", - ) - with v2.VBtn( - icon=True, - style="position: absolute; top: 8px; right: 8px; padding: 4px 8px; z-index: 2; color: white;", - click=(self.close_view, "[idx]"), - ): - v2.VIcon("mdi-close") - + Grid( + self.server, + self.viewmanager, + self.close_view, + ) return self._ui diff --git a/quickview/ui/grid.py b/quickview/ui/grid.py new file mode 100644 index 0000000..35035a5 --- /dev/null +++ b/quickview/ui/grid.py @@ -0,0 +1,447 @@ +from trame.widgets import grid +from trame.widgets import vuetify as v2, html, client +from quickview.ui.view_settings import ViewProperties +from datetime import datetime +from paraview.simple import SaveScreenshot +from paraview.simple import GetColorTransferFunction +from quickview.utils.color import ( + create_vertical_scalar_bar, + get_lut_from_color_transfer_function, +) +from PIL import Image +import tempfile +import os +import io + +from trame.decorators import trigger, TrameApp, task + + +@TrameApp() +class Grid: + @property + def state(self): + return self.server.state + + @property + def ctrl(self): + return self.server.controller + + @trigger("save_screenshot") + def save_screenshot(self, index): + """Generate and return screenshot data for browser download.""" + # Get the variable name and view + var = self.state.variables[index] + context = self.viewmanager.registry.get_view(var) + if context is None or context.state.view_proxy is None: + print(f"No view found for variable {var}") + return None + + view = context.state.view_proxy + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"quickview_{var}_{timestamp}.png" + + # Create temporary files + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + # Save main screenshot to temp file + SaveScreenshot(tmp_path, view) # , ImageResolution=[800, 600]) + # Read the original screenshot from ParaView + main_image = Image.open(tmp_path) + # Get log scale setting for label formatting + use_log = self.state.uselogscale[index] + + # Create vertical scalar bar configuration + class ScalarBarConfig: + def __init__(self): + self.scalar_bar_num_labels = 7 + self.scalar_bar_title = None # Could set to var name if desired + self.scalar_bar_title_font_size = 14 + self.scalar_bar_label_font_size = 12 + if use_log: + self.scalar_bar_label_format = "%.1e" + else: + self.scalar_bar_label_format = "%.2f" + + # Get the actual ParaView color transfer function being used for this variable + # This ensures we get the exact colormap that's displayed in the view + paraview_lut = GetColorTransferFunction(var) + + # The color transfer function is already configured with the correct + # colormap, inversion, log scale, and range from the view, so we don't + # need to modify it - just use it as is + + # Convert to VTK lookup table + vtk_lut = get_lut_from_color_transfer_function(paraview_lut, num_colors=256) + + # Create config + config = ScalarBarConfig() + config.scalar_bar_title = var + # Calculate colorbar width as 10% of main image width + colorbar_width = int(main_image.width * 0.15) + + # Create vertical scalar bar with same height as main image + colorbar_image = create_vertical_scalar_bar( + vtk_lut, colorbar_width, main_image.height, config + ) + + # Create extended image by combining original screenshot with scalar bar + # No artificial backgrounds - just extend the original image + composite_width = main_image.width + colorbar_image.width + composite = Image.new( + main_image.mode, # Use same mode as original image + (composite_width, main_image.height), + color=(255, 255, 255) + if main_image.mode == "RGB" + else (255, 255, 255, 255), + ) + + # Paste original screenshot and vertical colorbar + composite.paste(main_image, (0, 0)) + # Paste the colorbar with gradient background (no alpha mask needed) + composite.paste(colorbar_image, (main_image.width, 0)) + + # Save composite to bytes + output = io.BytesIO() + composite.save(output, format="PNG") + composite_bytes = output.getvalue() + + # Store filename in state for the download button to use + self.state.screenshot_filename = filename + + # Return the binary data as an attachment + return self.server.protocol.addAttachment(composite_bytes) + finally: + # Clean up temp file + if os.path.exists(tmp_path): + os.remove(tmp_path) + + @trigger("save_screenshot_tauri") + @task + async def save_screenshot_tauri(self, index): + os.write(1, "Executing Tauri!!!!".encode()) + """Generate screenshot and save to file using Tauri file dialog.""" + # Get the variable name and view + var = self.state.variables[index] + context = self.viewmanager.registry.get_view(var) + if context is None or context.state.view_proxy is None: + print(f"No view found for variable {var}") + return None + + view = context.state.view_proxy + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"quickview_{var}_{timestamp}.png" + + # Create temporary files + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Save main screenshot to temp file + SaveScreenshot(tmp_path, view) + + # Read the original screenshot from ParaView + main_image = Image.open(tmp_path) + + # Get log scale setting for label formatting + use_log = self.state.uselogscale[index] + + # Create vertical scalar bar configuration + class ScalarBarConfig: + def __init__(self): + self.scalar_bar_num_labels = 7 + self.scalar_bar_title = None + self.scalar_bar_title_font_size = 14 + self.scalar_bar_label_font_size = 12 + if use_log: + self.scalar_bar_label_format = "%.1e" + else: + self.scalar_bar_label_format = "%.2f" + + # Get the actual ParaView color transfer function being used for this variable + paraview_lut = GetColorTransferFunction(var) + + # Convert to VTK lookup table + vtk_lut = get_lut_from_color_transfer_function(paraview_lut, num_colors=256) + + # Create config + config = ScalarBarConfig() + config.scalar_bar_title = var + + # Calculate colorbar width as 15% of main image width + colorbar_width = int(main_image.width * 0.15) + + # Create vertical scalar bar with same height as main image + colorbar_image = create_vertical_scalar_bar( + vtk_lut, colorbar_width, main_image.height, config + ) + + # Create extended image by combining original screenshot with scalar bar + composite_width = main_image.width + colorbar_image.width + composite = Image.new( + main_image.mode, + (composite_width, main_image.height), + color=(255, 255, 255) + if main_image.mode == "RGB" + else (255, 255, 255, 255), + ) + + # Paste original screenshot and vertical colorbar + composite.paste(main_image, (0, 0)) + composite.paste(colorbar_image, (main_image.width, 0)) + + # Use Tauri's save dialog to get the save location + with self.state as state: + if state.tauri_avail: + # Open save dialog with suggested filename + response = await self.ctrl.save(f"Save Screenshot - {filename}") + if response: + # Save the composite image to the selected location + composite.save(response, format="PNG") + print(f"Screenshot saved to: {response}") + return {"success": True, "path": response} + else: + print("Tauri is not available") + return {"success": False, "error": "Tauri not available"} + + finally: + # Clean up temp file + if os.path.exists(tmp_path): + os.remove(tmp_path) + + def update_colormap(self, index, value): + """Update the colormap for a variable.""" + self.viewmanager.update_colormap(index, value) + + def update_log_scale(self, index, value): + """Update the log scale setting for a variable.""" + self.viewmanager.update_log_scale(index, value) + + def update_invert_colors(self, index, value): + """Update the color inversion setting for a variable.""" + self.viewmanager.update_invert_colors(index, value) + + def set_manual_color_range(self, index, type, value): + # Get current values from state to handle min/max independently + min_val = self.state.varmin[index] if type.lower() == "max" else value + max_val = self.state.varmax[index] if type.lower() == "min" else value + # Delegate to view manager which will update both the view and sync state + self.viewmanager.set_manual_color_range(index, min_val, max_val) + + def revert_to_auto_color_range(self, index): + self.viewmanager.revert_to_auto_color_range(index) + + def __init__( + self, + server, + view_manager=None, + close_view=None, + ): + self.server = server + self.viewmanager = view_manager + + with grid.GridLayout( + layout=("layout",), + col_num=12, + row_height=100, + is_draggable=True, + is_resizable=True, + vertical_compact=True, + layout_updated="layout = $event; trigger('layout_changed', [$event])", + ) as self.grid: + with grid.GridItem( + v_for="vref, idx in views", + key="vref", + v_bind=("layout[idx]",), + style="transition-property: none;", + ): + with v2.VCard(classes="fill-height", style="overflow: hidden;"): + with v2.VCardText( + style="height: calc(100% - 0.66rem); position: relative;", + classes="pa-0", + ) as cardcontent: + # VTK View fills entire space + cardcontent.add_child( + """ + + + """, + ) + client.ClientTriggers( + beforeDestroy="trigger('view_gc', [vref])", + # mounted=(self.viewmanager.reset_specific_view, '''[idx, + # {width: $refs[vref].vtkContainer.getBoundingClientRect().width, + # height: $refs[vref].vtkContainer.getBoundingClientRect().height}] + # ''') + ) + # Mask to prevent VTK view from getting scroll/mouse events + html.Div( + style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;" + ) + # Top-left info: time, level, variable name and average + with html.Div( + style="position: absolute; top: 8px; left: 8px; padding: 4px 8px; background-color: rgba(255, 255, 255, 0.1); color: white; font-size: 0.875rem; border-radius: 4px; z-index: 2;", + classes="drag-ignore font-monospace", + ): + # Variable name + html.Div( + "{{ variables[idx] }}", + style="color: white;", + classes="font-weight-medium", + ) + # Average value + html.Div( + ( + "(avg: {{ " + "varaverage[idx] !== null && varaverage[idx] !== undefined && !isNaN(varaverage[idx]) && typeof varaverage[idx] === 'number' ? " + "varaverage[idx].toExponential(2) : " + "'N/A' " + "}})" + ), + style="color: white;", + classes="font-weight-medium", + ) + # Show time + html.Div( + "t = {{ tstamp }}", + style="color: white;", + classes="font-weight-medium", + ) + # Show level for midpoint variables + html.Div( + v_if="midpoint_vars.includes(variables[idx])", + children="k = {{ midpoint }}", + style="color: white;", + classes="font-weight-medium", + ) + # Show level for interface variables + html.Div( + v_if="interface_vars.includes(variables[idx])", + children="k = {{ interface }}", + style="color: white;", + classes="font-weight-medium", + ) + # Colorbar container (horizontal layout at bottom) + with html.Div( + style="position: absolute; bottom: 8px; left: 8px; right: 8px; display: flex; align-items: center; justify-content: center; padding: 4px 8px 4px 8px; background-color: rgba(255, 255, 255, 0.1); height: 28px; z-index: 3; overflow: visible; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);", + classes="drag-ignore", + ): + # View Properties button (small icon) + ViewProperties( + update_colormap=self.update_colormap, + update_log_scale=self.update_log_scale, + update_invert=self.update_invert_colors, + update_range=self.set_manual_color_range, + reset=self.revert_to_auto_color_range, + style="margin-right: 8px; display: flex; align-items: center;", + ) + # Color min value + html.Span( + ( + "{{ " + "varmin[idx] !== null && varmin[idx] !== undefined && !isNaN(varmin[idx]) && typeof varmin[idx] === 'number' ? (" + "uselogscale[idx] && varmin[idx] > 0 ? " + "'10^(' + Math.log10(varmin[idx]).toFixed(1) + ')' : " + "varmin[idx].toExponential(1)" + ") : 'Auto' " + "}}" + ), + style="color: white;", + classes="font-weight-medium", + ) + # Colorbar + with html.Div( + style="flex: 1; display: flex; align-items: center; margin: 0 8px; height: 0.6rem; position: relative;", + classes="drag-ignore", + ): + # Colorbar image + html.Img( + src=( + "colorbar_images[idx] || 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='", + None, + ), + style="height: 100%; width: 100%; object-fit: fill;", + classes="rounded-lg border-thin", + v_on=( + "{" + "mousemove: (e) => { " + "const rect = e.target.getBoundingClientRect(); " + "const x = e.clientX - rect.left; " + "const width = rect.width; " + "const fraction = Math.max(0, Math.min(1, x / width)); " + "probe_location = [x, width, fraction, idx]; " + "}, " + "mouseenter: () => { probe_enabled = true; }, " + "mouseleave: () => { probe_enabled = false; probe_location = null; } " + "}" + ), + ) + # Probe tooltip (pan3d style - as sibling to colorbar) + html.Div( + v_if="probe_enabled && probe_location && probe_location[3] === idx", + v_bind_style="{position: 'absolute', bottom: '100%', left: probe_location[0] + 'px', transform: 'translateX(-50%)', marginBottom: '0.25rem', backgroundColor: '#000000', color: '#ffffff', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.875rem', whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 1000, fontFamily: 'monospace', boxShadow: '0 2px 4px rgba(0,0,0,0.3)'}", + children=( + "{{ " + "probe_location && varmin[idx] !== null && varmax[idx] !== null ? (" + "uselogscale[idx] && varmin[idx] > 0 && varmax[idx] > 0 ? " + "'10^(' + (" + "Math.log10(varmin[idx]) + " + "(Math.log10(varmax[idx]) - Math.log10(varmin[idx])) * probe_location[2]" + ").toFixed(2) + ')' : " + "((varmin[idx] || 0) + ((varmax[idx] || 1) - (varmin[idx] || 0)) * probe_location[2]).toExponential(3)" + ") : '' " + "}}" + ), + ) + # Color max value + html.Span( + ( + "{{ " + "varmax[idx] !== null && varmax[idx] !== undefined && !isNaN(varmax[idx]) && typeof varmax[idx] === 'number' ? (" + "uselogscale[idx] && varmax[idx] > 0 ? " + "'10^(' + Math.log10(varmax[idx]).toFixed(1) + ')' : " + "varmax[idx].toExponential(1)" + ") : 'Auto' " + "}}" + ), + style="color: white;", + classes="font-weight-medium", + ) + # Action buttons container (download and close) + with html.Div( + style="position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; z-index: 2;", + classes="drag-ignore", + ): + # Download screenshot button with tooltip + with v2.VTooltip(bottom=True): + with html.Template(v_slot_activator="{ on, attrs }"): + with v2.VBtn( + icon=True, + style="color: white; background-color: rgba(255, 255, 255, 0.1);", + click="tauri_avail ? trigger('save_screenshot_tauri', [idx]) : utils.download(`quickview_${variables[idx]}_${Date.now()}.png`, trigger('save_screenshot', [idx]), 'image/png')", + classes="ma-0", + v_bind="attrs", + v_on="on", + ): + v2.VIcon("mdi-file-download", small=True) + html.Span("Save Screenshot") + + # Close view button with tooltip + with v2.VTooltip(bottom=True): + with html.Template(v_slot_activator="{ on, attrs }"): + with v2.VBtn( + icon=True, + style="color: white; background-color: rgba(255, 255, 255, 0.1);", + click=(close_view, "[idx]"), + classes="ma-0", + v_bind="attrs", + v_on="on", + ): + v2.VIcon("mdi-close", small=True) + html.Span("Close View") + + pass diff --git a/quickview/ui/toolbar.py b/quickview/ui/toolbar.py index 000d52a..6df8e53 100644 --- a/quickview/ui/toolbar.py +++ b/quickview/ui/toolbar.py @@ -2,12 +2,6 @@ from trame.widgets import html, vuetify2 as v2 from quickview.ui.view_settings import ViewControls -try: - from trame.widgets import tauri -except ImportError: - # Fallback if tauri is not available - tauri = None - import json @@ -15,17 +9,35 @@ class Toolbar: @task async def select_data_file(self): - with self.state: - response = await self.ctrl.open("Open Data File") - self.state.data_file = response - self.state.pipeline_valid = False + with self.state as state: + if state.tauri_avail: + response = await self.ctrl.open("Open Data File") + self.state.data_file = response + self.state.pipeline_valid = False + else: + print("Tauri unavailable") + + def update_colormap(self, index, value): + """Update the colormap for a variable.""" + self.viewmanager.update_colormap(index, value) + + def update_log_scale(self, index, value): + """Update the log scale setting for a variable.""" + self.viewmanager.update_log_scale(index, value) + + def update_invert_colors(self, index, value): + """Update the color inversion setting for a variable.""" + self.viewmanager.update_invert_colors(index, value) @task async def select_connectivity_file(self): - with self.state: - response = await self.ctrl.open("Open Connectivity File") - self.state.conn_file = response - self.state.pipeline_valid = False + with self.state as state: + if state.tauri_avail: + response = await self.ctrl.open("Open Connectivity File") + self.state.conn_file = response + self.state.pipeline_valid = False + else: + print("Tauri unavailable") @task async def export_state(self): @@ -36,19 +48,25 @@ async def export_state(self): if self._generate_state is not None: config = self._generate_state() - with self.state: - response = await self.ctrl.save("Export State") - export_path = response - with open(export_path, "w") as file: - json.dump(config, file, indent=4) + with self.state as state: + if state.tauri_avail: + response = await self.ctrl.save("Export State") + export_path = response + with open(export_path, "w") as file: + json.dump(config, file, indent=4) + else: + print("Tauri unavailable") @task async def import_state(self): - with self.state: - response = await self.ctrl.open("Import State", filter=["json"]) - import_path = response - if self._load_state is not None: - self._load_state(import_path) + with self.state as state: + if state.tauri_avail: + response = await self.ctrl.open("Import State", filter=["json"]) + import_path = response + if self._load_state is not None: + self._load_state(import_path) + else: + print("Tauri unavailable") @property def state(self): @@ -94,14 +112,7 @@ def __init__( **kwargs, ): self.server = server - if tauri: - with tauri.Dialog() as dialog: - self.ctrl.open = dialog.open - self.ctrl.save = dialog.save - else: - # Fallback for non-tauri environments - self.ctrl.open = lambda title: None - self.ctrl.save = lambda title: None + self._generate_state = generate_state self._load_state = load_state self._update_available_color_maps = update_available_color_maps diff --git a/quickview/utils/color.py b/quickview/utils/color.py index d9466dc..8624e07 100644 --- a/quickview/utils/color.py +++ b/quickview/utils/color.py @@ -6,10 +6,18 @@ """ import base64 +import io import numpy as np +from PIL import Image from vtkmodules.vtkCommonCore import vtkUnsignedCharArray, vtkLookupTable from vtkmodules.vtkCommonDataModel import vtkImageData from vtkmodules.vtkIOImage import vtkPNGWriter +from vtkmodules.vtkRenderingCore import ( + vtkRenderer, + vtkRenderWindow, + vtkWindowToImageFilter, +) +from vtkmodules.vtkRenderingAnnotation import vtkScalarBarActor def get_lut_from_color_transfer_function(paraview_lut, num_colors=256): @@ -110,7 +118,7 @@ def vtk_lut_to_image(lut, samples=255): writer = vtkPNGWriter() writer.WriteToMemoryOn() writer.SetInputData(imgData) - writer.SetCompressionLevel(6) + writer.SetCompressionLevel(1) writer.Write() writer.GetResult() @@ -168,6 +176,232 @@ def get_cached_colorbar_image(colormap_name, inverted=False): return "" +def create_vertical_scalar_bar(vtk_lut, width, height, config): + """Create a vertical scalar bar image using VTK's ScalarBarActor. + + Parameters: + ----------- + vtk_lut : vtkLookupTable + The VTK lookup table for colors + width : int + Width of the scalar bar image + height : int + Height of the scalar bar image (should match main image height) + config : object + Configuration object with scalar bar settings + + Returns: + -------- + PIL.Image + Vertical scalar bar image with gradient background + """ + # Create a renderer and render window + renderer = vtkRenderer() + # Set gradient background from (0, 0, 42) at top to (84, 89, 109) at bottom + # Note: VTK uses bottom color first for gradient + renderer.SetBackground(84 / 255.0, 89 / 255.0, 109 / 255.0) # Bottom color + renderer.SetBackground2(0 / 255.0, 0 / 255.0, 42 / 255.0) # Top color + renderer.SetGradientBackground(True) + + # Use actual panel dimensions for render window + window_width = width + window_height = height + + render_window = vtkRenderWindow() + render_window.SetSize(window_width, window_height) + render_window.SetOffScreenRendering(1) + render_window.AddRenderer(renderer) + + # Create scalar bar actor + scalar_bar = vtkScalarBarActor() + scalar_bar.SetLookupTable(vtk_lut) + scalar_bar.SetNumberOfLabels(config.scalar_bar_num_labels) + + # Set orientation to vertical + scalar_bar.SetOrientationToVertical() + + # Set title if provided + if config.scalar_bar_title: + scalar_bar.SetTitle(config.scalar_bar_title) + + # Configure title text properties + title_prop = scalar_bar.GetTitleTextProperty() + title_prop.SetFontFamilyToArial() + title_prop.SetBold(True) + title_prop.SetFontSize(config.scalar_bar_title_font_size) + title_prop.SetColor(1, 1, 1) # white title text for visibility on dark background + + # Configure label text properties + label_prop = scalar_bar.GetLabelTextProperty() + label_prop.SetFontFamilyToArial() + label_prop.SetBold(False) + label_prop.SetFontSize(config.scalar_bar_label_font_size) + label_prop.SetColor(1, 1, 1) # white label text for visibility on dark background + + # Enable absolute font sizing + scalar_bar.UnconstrainedFontSizeOn() + + # Set the label format + scalar_bar.SetLabelFormat(config.scalar_bar_label_format) + # Configure scalar bar dimensions and position + # Make the color bar 30% of panel width with room for labels + # Leave more margin at top and bottom to prevent bleeding + scalar_bar.SetPosition(0.1, 0.1) # Start at 10% from left, 5% from bottom + scalar_bar.SetPosition2( + 0.9, 0.80 + ) # Use 90% of window width, 90% height (leaving 10% top margin) + + # Set the width of the color bar itself to be 30% of the actor width + # This gives the color bar proper visual presence + scalar_bar.SetBarRatio(0.3) # Color bar takes 30% of actor width + + # Add to renderer + renderer.AddActor(scalar_bar) + + # Render the scene + render_window.Render() + + # Convert to image + window_to_image = vtkWindowToImageFilter() + window_to_image.SetInput(render_window) + window_to_image.SetInputBufferTypeToRGBA() + window_to_image.Update() + + # Write to PNG in memory + writer = vtkPNGWriter() + writer.WriteToMemoryOn() + writer.SetInputConnection(window_to_image.GetOutputPort()) + writer.Write() + + # Convert to PIL Image + png_data = writer.GetResult() + img = Image.open(io.BytesIO(bytes(png_data))) + + # Keep the image as is with the gradient background + # No need to make anything transparent or crop + return img + + +def add_metadata_annotations(image, metadata, font_size=14): + """ + Add metadata annotations to an image (e.g., timestep, average, level). + + Parameters: + ----------- + image : PIL.Image + The image to annotate + metadata : dict + Metadata dictionary containing: + - variable_name: str + - average: float + - timestep: int + - level: int or None (for midpoint/interface variables) + - level_type: str ('midpoint' or 'interface') or None + font_size : int + Font size for the annotations + + Returns: + -------- + PIL.Image + Annotated image + """ + from PIL import Image, ImageDraw, ImageFont + + # Create a copy to avoid modifying the original + annotated = image.copy() + draw = ImageDraw.Draw(annotated) + + # Try to load a good font, fall back to default if not available + try: + # Try to load a monospace font for better alignment + font = ImageFont.truetype( + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", font_size + ) + except (OSError, IOError): + try: + # Try DejaVu Sans Mono as alternative + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size + ) + except (OSError, IOError): + # Fall back to default font + font = ImageFont.load_default() + + # Prepare metadata text lines + lines = [] + + # Variable name + if "variable_name" in metadata and metadata["variable_name"]: + lines.append(metadata["variable_name"]) + + # Average value + if "average" in metadata and metadata["average"] is not None: + if isinstance(metadata["average"], (int, float)): + # Format in scientific notation + lines.append(f"(avg: {metadata['average']:.2e})") + else: + lines.append("(avg: N/A)") + + # Time step + if "timestep" in metadata: + lines.append(f"t = {metadata['timestep']}") + + # Level (for midpoint/interface variables) + if "level" in metadata and metadata["level"] is not None: + if "level_type" in metadata: + if metadata["level_type"] == "midpoint": + lines.append(f"k = {metadata['level']} (midpoint)") + elif metadata["level_type"] == "interface": + lines.append(f"k = {metadata['level']} (interface)") + else: + lines.append(f"k = {metadata['level']}") + else: + lines.append(f"k = {metadata['level']}") + + # Calculate text positioning + x_offset = 10 + y_offset = 10 + line_height = font_size + 4 + + # Draw semi-transparent background for better text visibility + if lines: + # Calculate background size + max_width = 0 + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + text_width = bbox[2] - bbox[0] + max_width = max(max_width, text_width) + + background_width = max_width + 20 + background_height = len(lines) * line_height + 10 + + # Draw semi-transparent background rectangle + background = Image.new("RGBA", annotated.size, (0, 0, 0, 0)) + background_draw = ImageDraw.Draw(background) + background_draw.rectangle( + [ + (x_offset - 5, y_offset - 5), + (x_offset + background_width, y_offset + background_height), + ], + fill=(0, 0, 0, 128), # Semi-transparent black + ) + + # Composite the background onto the image + annotated = Image.alpha_composite( + annotated.convert("RGBA"), background + ).convert("RGB") + + # Redraw on the composited image + draw = ImageDraw.Draw(annotated) + + # Draw text lines + for i, line in enumerate(lines): + y_position = y_offset + i * line_height + draw.text((x_offset, y_position), line, fill=(255, 255, 255), font=font) + + return annotated + + # Auto-generated colorbar cache # This dictionary contains pre-generated base64-encoded colorbar images COLORBAR_CACHE = { diff --git a/quickview/view_manager.py b/quickview/view_manager.py index c8cfc1f..32dfeba 100644 --- a/quickview/view_manager.py +++ b/quickview/view_manager.py @@ -219,7 +219,7 @@ def _on_change_pipeline_valid(self, pipeline_valid, **kwargs): self.state.dirty("views") self.state.dirty("layout") - def close_view(self, var, index, layout_cache): + def close_view(self, var, index, layout_cache: map): # Clear cache and remove layout and widget entry with self.state as state: self.registry.remove_view(var) @@ -229,16 +229,8 @@ def close_view(self, var, index, layout_cache): state.uselogscale.pop(index) state.override_range.pop(index) state.invert.pop(index) - state.views.pop(index) state.colorbar_images.pop(index) - - # Rebuild layout - new_layout = [] - vars = self.state.variables - for var in vars: - cache = layout_cache[var] - new_layout.append(cache) - self.state.layout = new_layout + self.widgets.pop(index) self.state.dirty("varcolor") self.state.dirty("varmin") @@ -246,9 +238,8 @@ def close_view(self, var, index, layout_cache): self.state.dirty("uselogscale") self.state.dirty("override_range") self.state.dirty("invert") - self.state.dirty("layout") - self.state.dirty("views") self.state.dirty("colorbar_images") + self.rebuild_after_close(layout_cache) def update_views_for_timestep(self): if len(self.registry) == 0: @@ -429,7 +420,65 @@ def compute_range(self, var, vtkdata=None): vardata = vtkdata.GetCellData().GetArray(var) return vardata.GetRange() - def rebuild_visualization_layout(self, cached_layout=None): + def rebuild_after_close(self, cached_layout=None): + to_render = self.state.variables + rendered = self.registry.get_all_variables() + to_delete = set(rendered) - set(to_render) + # Move old variables so they their proxies can be deleted + self.to_delete.extend( + [self.registry.get_view(x).state.view_proxy for x in to_delete] + ) + + layout_map = cached_layout if cached_layout else {} + + del self.state.views[:] + del self.state.layout[:] + del self.widgets[:] + sWidgets = [] + layout = [] + wdt = 4 + hgt = 3 + + for index, var in enumerate(to_render): + # Check if we have saved position for this variable + if var in layout_map: + # Use saved position + pos = layout_map[var] + x = pos["x"] + y = pos["y"] + wdt = pos["w"] + hgt = pos["h"] + else: + # Default grid position (3 columns) + x = int(index % 3) * 4 + y = int(index / 3) * 3 + wdt = 4 + hgt = 3 + + context: ViewContext = self.registry.get_view(var) + view = context.state.view_proxy + context.index = index + widget = pvWidgets.VtkRemoteView( + view, + interactive_ratio=1, + classes="pa-0 drag_ignore", + style="width: 100%; height: 100%;", + trame_server=self.server, + ) + self.widgets.append(widget) + sWidgets.append(widget.ref_name) + # Use index as identifier to maintain compatibility with grid expectations + layout.append({"x": x, "y": y, "w": wdt, "h": hgt, "i": index}) + + for var in to_delete: + self.registry.remove_view(var) + + self.state.views = sWidgets + self.state.layout = layout + self.state.dirty("views") + self.state.dirty("layout") + + def rebuild_visualization_layout(self, cached_layout=None, update_pipeline=True): self.widgets.clear() state = self.state source = self.source @@ -438,15 +487,14 @@ def rebuild_visualization_layout(self, cached_layout=None): tstamp = state.tstamp time = 0.0 if len(self.state.timesteps) == 0 else self.state.timesteps[tstamp] - source.UpdateLev(self.state.midpoint, self.state.interface) - source.ApplyClipping(long, lat) - source.UpdateCenter(self.state.center) - source.UpdateProjection(self.state.projection) - source.UpdatePipeline(time) - surface_vars = source.vars.get("surface", []) - midpoint_vars = source.vars.get("midpoint", []) - interface_vars = source.vars.get("interface", []) - to_render = surface_vars + midpoint_vars + interface_vars + if update_pipeline: + source.UpdateLev(self.state.midpoint, self.state.interface) + source.ApplyClipping(long, lat) + source.UpdateCenter(self.state.center) + source.UpdateProjection(self.state.projection) + source.UpdatePipeline(time) + + to_render = self.state.variables rendered = self.registry.get_all_variables() to_delete = set(rendered) - set(to_render) # Move old variables so they their proxies can be deleted @@ -512,6 +560,7 @@ def rebuild_visualization_layout(self, cached_layout=None): view = context.state.view_proxy if view is None: view = CreateRenderView() + view.OrientationAxesVisibility = 0 view.UseColorPaletteForBackground = 0 view.BackgroundColorMode = "Gradient" view.GetRenderWindow().SetOffScreenRendering(True) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9234c63..563c7de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.0.1" +version = "1.0.2" description = "QuickView: Visual Analyis for E3SM Atmosphere Data" authors = ["Kitware"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index eb2a8c3..dda2fb2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ }, "package": { "productName": "QuickView", - "version": "1.0.1" + "version": "1.0.2" }, "tauri": { "allowlist": {