From baf210e8ea8322a746e50f5cd5a6a2475eb8ed9c Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Thu, 28 Aug 2025 23:11:37 -0700 Subject: [PATCH 01/10] fix: Add explicit reminder to quarantine --- .github/workflows/finalize-release.yml | 5 +++++ 1 file changed, 5 insertions(+) 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: From 18503ff8776da18f88d5574b11a2162f25593d79 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure <22721736+ayenpure@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:05:39 -0700 Subject: [PATCH 02/10] docs: Update for_app_developers.md Removing unnecessary steps -- we only use conda now, no venv is needed. --- docs/setup/for_app_developers.md | 10 ---------- 1 file changed, 10 deletions(-) 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 From 87e5ae710ee73409f26fe27fbf564222aa8f7a4e Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Fri, 5 Sep 2025 21:13:51 -0700 Subject: [PATCH 03/10] fix: Preserve resizing and reposition of layout after closing one --- quickview/view_manager.py | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/quickview/view_manager.py b/quickview/view_manager.py index c8cfc1f..f71930c 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,26 +229,16 @@ 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.state.dirty("varcolor") self.state.dirty("varmin") self.state.dirty("varmax") 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_visualization_layout(layout_cache, False) def update_views_for_timestep(self): if len(self.registry) == 0: @@ -429,7 +419,7 @@ 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_visualization_layout(self, cached_layout=None, update_pipeline=True): self.widgets.clear() state = self.state source = self.source @@ -438,15 +428,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 From 1dd938d8c57f48001b8063a1ceef4377c7107786 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Sat, 6 Sep 2025 11:44:33 -0700 Subject: [PATCH 04/10] feat: initial crude save screenshot --- quickview/interface.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/quickview/interface.py b/quickview/interface.py index 6b6f383..1842291 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -712,6 +712,50 @@ def clear_interface_vars(self, clear_var_name): self.interface_vars_state = np.array([False] * len(self.source.interface_vars)) self.state.dirty("interface_vars_state") + @trigger("save_screenshot") + def save_screenshot(self, index): + """Generate and return screenshot data for download.""" + from datetime import datetime + from paraview.simple import SaveScreenshot + import tempfile + import os + + # 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 for state + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"quickview_{var}_{timestamp}.png" + + # Create temporary file for screenshot + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Save screenshot to temp file + SaveScreenshot(tmp_path, view, ImageResolution=[1920, 1080]) + + # Read the screenshot and return as attachment + with open(tmp_path, "rb") as f: + screenshot_bytes = f.read() + + # 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(screenshot_bytes) + + finally: + # Clean up temp file + if os.path.exists(tmp_path): + os.remove(tmp_path) + def close_view(self, index): var = self.state.variables.pop(index) origin = self.state.varorigin.pop(index) @@ -994,6 +1038,12 @@ def ui(self) -> SinglePageWithDrawerLayout: style="color: white;", classes="font-weight-medium", ) + with v2.VBtn( + icon=True, + style="position: absolute; top: 8px; right: 24px; padding: 4px 8px; z-index: 2; color: white;", + click="utils.download(`quickview_${variables[idx]}_${Date.now()}.png`, trigger('save_screenshot', [idx]), 'image/png')", + ): + v2.VIcon("mdi-file-download") with v2.VBtn( icon=True, style="position: absolute; top: 8px; right: 8px; padding: 4px 8px; z-index: 2; color: white;", From da9d7d794fd37ee6b4c49049b0f33f1ca33cc276 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Sat, 6 Sep 2025 21:58:10 -0700 Subject: [PATCH 05/10] feat: Multiple features and fixes - Adding save screenshot omitting VTK - Simplify close view logic --- quickview/interface.py | 138 +++++++++++++--- quickview/utils/color.py | 323 +++++++++++++++++++++++++++++++++++++- quickview/view_manager.py | 62 +++++++- 3 files changed, 502 insertions(+), 21 deletions(-) diff --git a/quickview/interface.py b/quickview/interface.py index 1842291..2d4ba28 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -717,8 +717,14 @@ def save_screenshot(self, index): """Generate and return screenshot data for download.""" from datetime import datetime from paraview.simple import SaveScreenshot + from quickview.utils.color import ( + create_horizontal_annotated_colorbar, + add_metadata_annotations, + ) + from PIL import Image import tempfile import os + import io # Get the variable name and view var = self.state.variables[index] @@ -733,23 +739,91 @@ def save_screenshot(self, index): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"quickview_{var}_{timestamp}.png" - # Create temporary file for screenshot + # Create temporary files with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: tmp_path = tmp_file.name try: - # Save screenshot to temp file - SaveScreenshot(tmp_path, view, ImageResolution=[1920, 1080]) - - # Read the screenshot and return as attachment - with open(tmp_path, "rb") as f: - screenshot_bytes = f.read() + # Save main screenshot to temp file + SaveScreenshot(tmp_path, view) # , ImageResolution=[800, 600]) + + # Read the screenshot + main_image = Image.open(tmp_path) + + # Get colormap info from state + colormap_name = self.state.varcolor[index] + inverted = self.state.invert[index] + + # Get min/max values from state + min_val = self.state.varmin[index] + max_val = self.state.varmax[index] + use_log = self.state.uselogscale[index] + + # Add margins around the main image to prevent edge clipping + margin = 20 # Margin around the screenshot + main_with_margins = Image.new( + "RGB", + (main_image.width + 2 * margin, main_image.height + 2 * margin), + "black", + ) + main_with_margins.paste(main_image, (margin, margin)) + + # Prepare metadata for annotations + metadata = { + "variable_name": var, + "average": self.state.varaverage[index] + if index < len(self.state.varaverage) + else None, + "timestep": self.state.tstamp, + } + + # Check if this is a midpoint or interface variable + if var in self.state.midpoint_vars: + metadata["level"] = self.state.midpoint + metadata["level_type"] = "midpoint" + elif var in self.state.interface_vars: + metadata["level"] = self.state.interface + metadata["level_type"] = "interface" + + # Add metadata annotations to the main image with margins + annotated_main = add_metadata_annotations( + main_with_margins, metadata, font_size=16 + ) + + # Create horizontal annotated colorbar using cached images + # Use optimized height for better space efficiency + colorbar_height = 90 if use_log else 60 + colorbar_image = create_horizontal_annotated_colorbar( + colormap_name, + inverted, + min_val, + max_val, + width=annotated_main.width, # Match main image width (including margins) + height=colorbar_height, + num_ticks=7, + use_log_scale=use_log, + ) + + # Create composite image with annotated screenshot and horizontal colorbar at bottom + composite_height = annotated_main.height + colorbar_image.height + composite = Image.new( + "RGB", (annotated_main.width, composite_height), "black" + ) + + # Paste annotated main image and colorbar + composite.paste(annotated_main, (0, 0)) + composite.paste(colorbar_image, (0, annotated_main.height)) + + # 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(screenshot_bytes) + return self.server.protocol.addAttachment(composite_bytes) finally: # Clean up temp file @@ -1038,17 +1112,43 @@ def ui(self) -> SinglePageWithDrawerLayout: style="color: white;", classes="font-weight-medium", ) - with v2.VBtn( - icon=True, - style="position: absolute; top: 8px; right: 24px; padding: 4px 8px; z-index: 2; color: white;", - click="utils.download(`quickview_${variables[idx]}_${Date.now()}.png`, trigger('save_screenshot', [idx]), 'image/png')", - ): - v2.VIcon("mdi-file-download") - 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]"), + # 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", ): - v2.VIcon("mdi-close") + # 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="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=(self.close_view, "[idx]"), + classes="ma-0", + v_bind="attrs", + v_on="on", + ): + v2.VIcon("mdi-close", small=True) + html.Span("Close View") return self._ui diff --git a/quickview/utils/color.py b/quickview/utils/color.py index d9466dc..6ab0403 100644 --- a/quickview/utils/color.py +++ b/quickview/utils/color.py @@ -110,7 +110,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 +168,327 @@ def get_cached_colorbar_image(colormap_name, inverted=False): return "" +def create_horizontal_annotated_colorbar( + colormap_name, + inverted, + min_val, + max_val, + width=500, + height=80, + num_ticks=7, + use_log_scale=False, +): + """ + Create a horizontal annotated colorbar using cached colorbar images. + + Parameters: + ----------- + colormap_name : str + Name of the colormap + inverted : bool + Whether colors are inverted + min_val : float + Minimum value for the colorbar + max_val : float + Maximum value for the colorbar + width : int + Width of the colorbar image (default: 500) + height : int + Height of the colorbar including labels (default: 80) + num_ticks : int + Number of tick marks (default: 7) + use_log_scale : bool + Whether to use logarithmic spacing for labels + + Returns: + -------- + PIL.Image + Annotated colorbar image + """ + from PIL import Image, ImageDraw, ImageFont + import base64 + import io + import math + + # Add margins to prevent label clipping + margin = 50 # Left and right margins for text + colorbar_width = width - 2 * margin + + # Get cached colorbar data URI + colorbar_data = get_cached_colorbar_image(colormap_name, inverted) + if not colorbar_data: + # Create a fallback gray gradient if colormap not found + colorbar_img = Image.new("RGB", (colorbar_width, 20), (128, 128, 128)) + else: + # Extract base64 data from data URI + base64_data = colorbar_data.split(",")[1] + img_data = base64.b64decode(base64_data) + colorbar_img = Image.open(io.BytesIO(img_data)) + # Resize to desired width while maintaining aspect + colorbar_img = colorbar_img.resize((colorbar_width, 20), Image.LANCZOS) + + # Create new image with space for labels + annotated = Image.new("RGB", (width, height), (0, 0, 0)) + + # Position colorbar more efficiently + if use_log_scale: + # For log scale with alternating labels, position near the middle but optimize space + colorbar_y_position = 35 # Leave 35px above for upper labels + else: + colorbar_y_position = 5 # Small padding at top for linear scale + + annotated.paste(colorbar_img, (margin, colorbar_y_position)) + + draw = ImageDraw.Draw(annotated) + + # Load font - try more legible fonts first with smaller size for compactness + try: + # Try Liberation Sans for better legibility (not monospace for tighter spacing) + font = ImageFont.truetype( + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 10 + ) + except (OSError, IOError): + try: + # Try DejaVu Sans + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10 + ) + except (OSError, IOError): + try: + # Fallback to Arial if available + font = ImageFont.truetype( + "/usr/share/fonts/truetype/msttcorefonts/arial.ttf", 10 + ) + except (OSError, IOError): + # Last resort - default font + font = ImageFont.load_default() + + # Calculate tick positions and values + if use_log_scale and min_val > 0: + # For log scale: generate log-spaced values but position them correctly on linear gradient + log_min = math.log10(min_val) + log_max = math.log10(max_val) + log_positions = [ + log_min + i * (log_max - log_min) / (num_ticks - 1) + for i in range(num_ticks) + ] + tick_values = [10**pos for pos in log_positions] + + # Calculate where these log values should appear on the linear gradient + # Map each log value to its linear position in the min_val to max_val range + tick_positions = [(val - min_val) / (max_val - min_val) for val in tick_values] + else: + # Linear spacing + tick_values = [ + min_val + i * (max_val - min_val) / (num_ticks - 1) + for i in range(num_ticks) + ] + # For linear scale, positions are evenly spaced + tick_positions = [i / (num_ticks - 1) for i in range(num_ticks)] + + # Draw ticks and labels + + for tick_index, (value, position) in enumerate(zip(tick_values, tick_positions)): + # Calculate position within the colorbar area using the calculated position + x_pos = margin + int(position * (colorbar_width - 1)) + + # Draw tick mark relative to colorbar position + tick_top = colorbar_y_position + 20 # Bottom of colorbar + tick_bottom = tick_top + 3 # Shorter 3px tick for more compact design + draw.line( + [(x_pos, tick_top), (x_pos, tick_bottom)], fill=(255, 255, 255), width=1 + ) + + # Format label - use shorter format for log scale to reduce overlap + if use_log_scale: + if abs(value) < 0.01 or abs(value) > 10000: + label = f"{value:.1e}" # Shorter scientific notation + else: + label = f"{value:.3g}" # General format, shorter + elif abs(value) < 0.01 or abs(value) > 10000: + label = f"{value:.2e}" + elif abs(value) < 1: + label = f"{value:.4f}" + elif abs(value) < 10: + label = f"{value:.2f}" + else: + label = f"{value:.1f}" + + if use_log_scale: + # For log scale, use alternating up/down positioning with 90-degree rotated text + # First, measure the text to create appropriately sized temp image + bbox = draw.textbbox((0, 0), label, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Create temp image with some padding + temp_img = Image.new( + "RGBA", (text_width + 10, text_height + 10), (0, 0, 0, 0) + ) + temp_draw = ImageDraw.Draw(temp_img) + + # Draw text with small padding + temp_draw.text((5, 5), label, fill=(255, 255, 255), font=font) + + # Rotate the text 90 degrees (vertical) + rotated_text = temp_img.rotate(90, expand=True) + + # Alternate positioning: even indices below, odd indices above + text_x = x_pos - rotated_text.width // 2 # Center under tick + + if tick_index % 2 == 0: + # Even index: position below the colorbar + text_y = tick_bottom + 1 # Tighter spacing below tick marks + else: + # Odd index: position above the colorbar + text_y = ( + colorbar_y_position - rotated_text.height - 1 + ) # Tighter spacing above + + # Ensure text stays within bounds + text_x = max(0, min(text_x, width - rotated_text.width)) + # Don't clamp Y for now to see where labels are going + + # Paste the rotated text onto the main image + if rotated_text.mode != "RGBA": + rotated_text = rotated_text.convert("RGBA") + annotated.paste(rotated_text, (text_x, text_y), rotated_text) + else: + # For linear scale, use horizontal text as before + bbox = draw.textbbox((0, 0), label, font=font) + text_width = bbox[2] - bbox[0] + text_x = x_pos - text_width // 2 + + # Clamp text position to stay within margins + text_x = max(5, min(text_x, width - text_width - 5)) + + # Position text below the tick marks with tighter spacing + text_y = tick_bottom + 3 + draw.text((text_x, text_y), label, fill=(255, 255, 255), font=font) + + return annotated + + +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 f71930c..32dfeba 100644 --- a/quickview/view_manager.py +++ b/quickview/view_manager.py @@ -230,6 +230,7 @@ def close_view(self, var, index, layout_cache: map): state.override_range.pop(index) state.invert.pop(index) state.colorbar_images.pop(index) + self.widgets.pop(index) self.state.dirty("varcolor") self.state.dirty("varmin") @@ -238,7 +239,7 @@ def close_view(self, var, index, layout_cache: map): self.state.dirty("override_range") self.state.dirty("invert") self.state.dirty("colorbar_images") - self.rebuild_visualization_layout(layout_cache, False) + self.rebuild_after_close(layout_cache) def update_views_for_timestep(self): if len(self.registry) == 0: @@ -419,6 +420,64 @@ def compute_range(self, var, vtkdata=None): vardata = vtkdata.GetCellData().GetArray(var) return vardata.GetRange() + 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 @@ -501,6 +560,7 @@ def rebuild_visualization_layout(self, cached_layout=None, update_pipeline=True) view = context.state.view_proxy if view is None: view = CreateRenderView() + view.OrientationAxesVisibility = 0 view.UseColorPaletteForBackground = 0 view.BackgroundColorMode = "Gradient" view.GetRenderWindow().SetOffScreenRendering(True) From 74f1e5c88c82723f34754c2d0143e2c36f7e101b Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Mon, 15 Sep 2025 12:40:02 -0700 Subject: [PATCH 06/10] fix: Simply save screenshot logic --- quickview/interface.py | 108 +++++++-------- quickview/utils/color.py | 283 ++++++++++++++------------------------- 2 files changed, 147 insertions(+), 244 deletions(-) diff --git a/quickview/interface.py b/quickview/interface.py index 2d4ba28..99ebb70 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -717,9 +717,10 @@ def save_screenshot(self, index): """Generate and return screenshot data for download.""" from datetime import datetime from paraview.simple import SaveScreenshot + from paraview.simple import GetColorTransferFunction from quickview.utils.color import ( - create_horizontal_annotated_colorbar, - add_metadata_annotations, + create_vertical_scalar_bar, + get_lut_from_color_transfer_function, ) from PIL import Image import tempfile @@ -747,72 +748,61 @@ def save_screenshot(self, index): # Save main screenshot to temp file SaveScreenshot(tmp_path, view) # , ImageResolution=[800, 600]) - # Read the screenshot + # Read the original screenshot from ParaView main_image = Image.open(tmp_path) - # Get colormap info from state - colormap_name = self.state.varcolor[index] - inverted = self.state.invert[index] - - # Get min/max values from state - min_val = self.state.varmin[index] - max_val = self.state.varmax[index] + # Get log scale setting for label formatting use_log = self.state.uselogscale[index] - # Add margins around the main image to prevent edge clipping - margin = 20 # Margin around the screenshot - main_with_margins = Image.new( - "RGB", - (main_image.width + 2 * margin, main_image.height + 2 * margin), - "black", - ) - main_with_margins.paste(main_image, (margin, margin)) - - # Prepare metadata for annotations - metadata = { - "variable_name": var, - "average": self.state.varaverage[index] - if index < len(self.state.varaverage) - else None, - "timestep": self.state.tstamp, - } - - # Check if this is a midpoint or interface variable - if var in self.state.midpoint_vars: - metadata["level"] = self.state.midpoint - metadata["level_type"] = "midpoint" - elif var in self.state.interface_vars: - metadata["level"] = self.state.interface - metadata["level_type"] = "interface" - - # Add metadata annotations to the main image with margins - annotated_main = add_metadata_annotations( - main_with_margins, metadata, font_size=16 - ) - - # Create horizontal annotated colorbar using cached images - # Use optimized height for better space efficiency - colorbar_height = 90 if use_log else 60 - colorbar_image = create_horizontal_annotated_colorbar( - colormap_name, - inverted, - min_val, - max_val, - width=annotated_main.width, # Match main image width (including margins) - height=colorbar_height, - num_ticks=7, - use_log_scale=use_log, + # 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 composite image with annotated screenshot and horizontal colorbar at bottom - composite_height = annotated_main.height + colorbar_image.height + # 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( - "RGB", (annotated_main.width, composite_height), "black" + 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 annotated main image and colorbar - composite.paste(annotated_main, (0, 0)) - composite.paste(colorbar_image, (0, annotated_main.height)) + # 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() diff --git a/quickview/utils/color.py b/quickview/utils/color.py index 6ab0403..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): @@ -168,205 +176,110 @@ def get_cached_colorbar_image(colormap_name, inverted=False): return "" -def create_horizontal_annotated_colorbar( - colormap_name, - inverted, - min_val, - max_val, - width=500, - height=80, - num_ticks=7, - use_log_scale=False, -): - """ - Create a horizontal annotated colorbar using cached colorbar images. +def create_vertical_scalar_bar(vtk_lut, width, height, config): + """Create a vertical scalar bar image using VTK's ScalarBarActor. Parameters: ----------- - colormap_name : str - Name of the colormap - inverted : bool - Whether colors are inverted - min_val : float - Minimum value for the colorbar - max_val : float - Maximum value for the colorbar + vtk_lut : vtkLookupTable + The VTK lookup table for colors width : int - Width of the colorbar image (default: 500) + Width of the scalar bar image height : int - Height of the colorbar including labels (default: 80) - num_ticks : int - Number of tick marks (default: 7) - use_log_scale : bool - Whether to use logarithmic spacing for labels + Height of the scalar bar image (should match main image height) + config : object + Configuration object with scalar bar settings Returns: -------- PIL.Image - Annotated colorbar image + Vertical scalar bar image with gradient background """ - from PIL import Image, ImageDraw, ImageFont - import base64 - import io - import math - - # Add margins to prevent label clipping - margin = 50 # Left and right margins for text - colorbar_width = width - 2 * margin - - # Get cached colorbar data URI - colorbar_data = get_cached_colorbar_image(colormap_name, inverted) - if not colorbar_data: - # Create a fallback gray gradient if colormap not found - colorbar_img = Image.new("RGB", (colorbar_width, 20), (128, 128, 128)) - else: - # Extract base64 data from data URI - base64_data = colorbar_data.split(",")[1] - img_data = base64.b64decode(base64_data) - colorbar_img = Image.open(io.BytesIO(img_data)) - # Resize to desired width while maintaining aspect - colorbar_img = colorbar_img.resize((colorbar_width, 20), Image.LANCZOS) - - # Create new image with space for labels - annotated = Image.new("RGB", (width, height), (0, 0, 0)) - - # Position colorbar more efficiently - if use_log_scale: - # For log scale with alternating labels, position near the middle but optimize space - colorbar_y_position = 35 # Leave 35px above for upper labels - else: - colorbar_y_position = 5 # Small padding at top for linear scale - - annotated.paste(colorbar_img, (margin, colorbar_y_position)) - - draw = ImageDraw.Draw(annotated) - - # Load font - try more legible fonts first with smaller size for compactness - try: - # Try Liberation Sans for better legibility (not monospace for tighter spacing) - font = ImageFont.truetype( - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 10 - ) - except (OSError, IOError): - try: - # Try DejaVu Sans - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10 - ) - except (OSError, IOError): - try: - # Fallback to Arial if available - font = ImageFont.truetype( - "/usr/share/fonts/truetype/msttcorefonts/arial.ttf", 10 - ) - except (OSError, IOError): - # Last resort - default font - font = ImageFont.load_default() - - # Calculate tick positions and values - if use_log_scale and min_val > 0: - # For log scale: generate log-spaced values but position them correctly on linear gradient - log_min = math.log10(min_val) - log_max = math.log10(max_val) - log_positions = [ - log_min + i * (log_max - log_min) / (num_ticks - 1) - for i in range(num_ticks) - ] - tick_values = [10**pos for pos in log_positions] - - # Calculate where these log values should appear on the linear gradient - # Map each log value to its linear position in the min_val to max_val range - tick_positions = [(val - min_val) / (max_val - min_val) for val in tick_values] - else: - # Linear spacing - tick_values = [ - min_val + i * (max_val - min_val) / (num_ticks - 1) - for i in range(num_ticks) - ] - # For linear scale, positions are evenly spaced - tick_positions = [i / (num_ticks - 1) for i in range(num_ticks)] - - # Draw ticks and labels + # 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() - for tick_index, (value, position) in enumerate(zip(tick_values, tick_positions)): - # Calculate position within the colorbar area using the calculated position - x_pos = margin + int(position * (colorbar_width - 1)) - - # Draw tick mark relative to colorbar position - tick_top = colorbar_y_position + 20 # Bottom of colorbar - tick_bottom = tick_top + 3 # Shorter 3px tick for more compact design - draw.line( - [(x_pos, tick_top), (x_pos, tick_bottom)], fill=(255, 255, 255), width=1 - ) - - # Format label - use shorter format for log scale to reduce overlap - if use_log_scale: - if abs(value) < 0.01 or abs(value) > 10000: - label = f"{value:.1e}" # Shorter scientific notation - else: - label = f"{value:.3g}" # General format, shorter - elif abs(value) < 0.01 or abs(value) > 10000: - label = f"{value:.2e}" - elif abs(value) < 1: - label = f"{value:.4f}" - elif abs(value) < 10: - label = f"{value:.2f}" - else: - label = f"{value:.1f}" - - if use_log_scale: - # For log scale, use alternating up/down positioning with 90-degree rotated text - # First, measure the text to create appropriately sized temp image - bbox = draw.textbbox((0, 0), label, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - - # Create temp image with some padding - temp_img = Image.new( - "RGBA", (text_width + 10, text_height + 10), (0, 0, 0, 0) - ) - temp_draw = ImageDraw.Draw(temp_img) - - # Draw text with small padding - temp_draw.text((5, 5), label, fill=(255, 255, 255), font=font) - - # Rotate the text 90 degrees (vertical) - rotated_text = temp_img.rotate(90, expand=True) - - # Alternate positioning: even indices below, odd indices above - text_x = x_pos - rotated_text.width // 2 # Center under tick - - if tick_index % 2 == 0: - # Even index: position below the colorbar - text_y = tick_bottom + 1 # Tighter spacing below tick marks - else: - # Odd index: position above the colorbar - text_y = ( - colorbar_y_position - rotated_text.height - 1 - ) # Tighter spacing above - - # Ensure text stays within bounds - text_x = max(0, min(text_x, width - rotated_text.width)) - # Don't clamp Y for now to see where labels are going - - # Paste the rotated text onto the main image - if rotated_text.mode != "RGBA": - rotated_text = rotated_text.convert("RGBA") - annotated.paste(rotated_text, (text_x, text_y), rotated_text) - else: - # For linear scale, use horizontal text as before - bbox = draw.textbbox((0, 0), label, font=font) - text_width = bbox[2] - bbox[0] - text_x = x_pos - text_width // 2 + # Convert to image + window_to_image = vtkWindowToImageFilter() + window_to_image.SetInput(render_window) + window_to_image.SetInputBufferTypeToRGBA() + window_to_image.Update() - # Clamp text position to stay within margins - text_x = max(5, min(text_x, width - text_width - 5)) + # Write to PNG in memory + writer = vtkPNGWriter() + writer.WriteToMemoryOn() + writer.SetInputConnection(window_to_image.GetOutputPort()) + writer.Write() - # Position text below the tick marks with tighter spacing - text_y = tick_bottom + 3 - draw.text((text_x, text_y), label, fill=(255, 255, 255), font=font) + # Convert to PIL Image + png_data = writer.GetResult() + img = Image.open(io.BytesIO(bytes(png_data))) - return annotated + # 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): From f35fc4111ce5d91544bd542c6b08ff51bbc7e27f Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Mon, 15 Sep 2025 14:39:04 -0700 Subject: [PATCH 07/10] fix: Check for tauri --- quickview/interface.py | 5 +++++ quickview/ui/toolbar.py | 48 +++++++++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/quickview/interface.py b/quickview/interface.py index 99ebb70..e3d97dd 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -162,6 +162,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 "" @@ -857,6 +858,10 @@ def ui(self) -> SinglePageWithDrawerLayout: with self._ui as layout: layout.footer.clear() layout.title.clear() + 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;", diff --git a/quickview/ui/toolbar.py b/quickview/ui/toolbar.py index 000d52a..6830551 100644 --- a/quickview/ui/toolbar.py +++ b/quickview/ui/toolbar.py @@ -15,17 +15,23 @@ 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") @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 +42,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): From ae8e5231c348af8286da6bb2ac69d8b1d5a3d13a Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 16 Sep 2025 08:47:22 -0700 Subject: [PATCH 08/10] fix: Adding Grid refactor and tauri/browser detection --- quickview/interface.py | 397 +++------------------------------ quickview/ui/grid.py | 476 ++++++++++++++++++++++++++++++++++++++++ quickview/ui/toolbar.py | 27 ++- 3 files changed, 516 insertions(+), 384 deletions(-) create mode 100644 quickview/ui/grid.py diff --git a/quickview/interface.py b/quickview/interface.py index e3d97dd..2291437 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 ) @@ -538,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 @@ -567,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() @@ -713,140 +693,6 @@ def clear_interface_vars(self, clear_var_name): self.interface_vars_state = np.array([False] * len(self.source.interface_vars)) self.state.dirty("interface_vars_state") - @trigger("save_screenshot") - def save_screenshot(self, index): - """Generate and return screenshot data for download.""" - 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 - - # 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 for state - 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) - - def close_view(self, index): - var = self.state.variables.pop(index) - origin = self.state.varorigin.pop(index) - self._cached_layout.pop(var) - self.state.dirty("variables") - self.state.dirty("varorigin") - self.viewmanager.close_view(var, index, self._cached_layout) - state = self.state - - # Find variable to unselect from the UI - if origin == 0: - # Find and clear surface display - if var in state.surface_vars: - var_index = state.surface_vars.index(var) - self.update_surface_var_selection(var_index, False) - elif origin == 1: - # Find and clear midpoints display - if var in state.midpoint_vars: - var_index = state.midpoint_vars.index(var) - self.update_midpoint_var_selection(var_index, False) - elif origin == 2: - # Find and clear interface display - if var in state.interface_vars: - var_index = state.interface_vars.index(var) - self.update_interface_var_selection(var_index, False) - def start(self, **kwargs): """Initialize the UI and start the server for GeoTrame.""" self.ui.server.start(**kwargs) @@ -858,6 +704,18 @@ 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;" ) @@ -938,212 +796,11 @@ 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", - ) - # 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="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=(self.close_view, "[idx]"), - classes="ma-0", - v_bind="attrs", - v_on="on", - ): - v2.VIcon("mdi-close", small=True) - html.Span("Close View") - + Grid( + self.server, + self.viewmanager, + self.update_surface_var_selection, + self.update_midpoint_var_selection, + self.update_interface_var_selection, + ) return self._ui diff --git a/quickview/ui/grid.py b/quickview/ui/grid.py new file mode 100644 index 0000000..79ffff6 --- /dev/null +++ b/quickview/ui/grid.py @@ -0,0 +1,476 @@ +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) + + @task + async def save_screenshot_tauri(self, index): + """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 close_view(self, index): + var = self.state.variables.pop(index) + origin = self.state.varorigin.pop(index) + self._cached_layout.pop(var) + self.state.dirty("variables") + self.state.dirty("varorigin") + self.viewmanager.close_view(var, index, self._cached_layout) + state = self.state + + # Find variable to unselect from the UI + if origin == 0: + # Find and clear surface display + if var in state.surface_vars: + var_index = state.surface_vars.index(var) + self.update_surface_var_selection(var_index, False) + elif origin == 1: + # Find and clear midpoints display + if var in state.midpoint_vars: + var_index = state.midpoint_vars.index(var) + self.update_midpoint_var_selection(var_index, False) + elif origin == 2: + # Find and clear interface display + if var in state.interface_vars: + var_index = state.interface_vars.index(var) + self.update_interface_var_selection(var_index, False) + + def __init__( + self, + server, + view_manager=None, + update_surface_var_selection=None, + update_midpoint_var_selection=None, + update_interface_var_selection=None, + ): + self.server = server + self.viewmanager = view_manager + self.update_surface_var_selection = update_surface_var_selection + self.update_midpoint_var_selection = update_midpoint_var_selection + self.update_interface_var_selection = update_interface_var_selection + + 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=(self.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 6830551..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 @@ -23,6 +17,18 @@ async def select_data_file(self): 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 as state: @@ -106,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 From 4e21084867669de06c0c1affa34c6a1a3e4b47dc Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 16 Sep 2025 10:54:33 -0700 Subject: [PATCH 09/10] fix: close view refactor and save screenshot w/ Tauri --- quickview/interface.py | 30 +++++++++++++++++++++++++++--- quickview/ui/grid.py | 37 ++++--------------------------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/quickview/interface.py b/quickview/interface.py index 2291437..76005c0 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -693,6 +693,32 @@ def clear_interface_vars(self, clear_var_name): self.interface_vars_state = np.array([False] * len(self.source.interface_vars)) self.state.dirty("interface_vars_state") + def close_view(self, index): + var = self.state.variables.pop(index) + origin = self.state.varorigin.pop(index) + self._cached_layout.pop(var) + self.state.dirty("variables") + self.state.dirty("varorigin") + self.viewmanager.close_view(var, index, self._cached_layout) + state = self.state + + # Find variable to unselect from the UI + if origin == 0: + # Find and clear surface display + if var in state.surface_vars: + var_index = state.surface_vars.index(var) + self.update_surface_var_selection(var_index, False) + elif origin == 1: + # Find and clear midpoints display + if var in state.midpoint_vars: + var_index = state.midpoint_vars.index(var) + self.update_midpoint_var_selection(var_index, False) + elif origin == 2: + # Find and clear interface display + if var in state.interface_vars: + var_index = state.interface_vars.index(var) + self.update_interface_var_selection(var_index, False) + def start(self, **kwargs): """Initialize the UI and start the server for GeoTrame.""" self.ui.server.start(**kwargs) @@ -799,8 +825,6 @@ def ui(self) -> SinglePageWithDrawerLayout: Grid( self.server, self.viewmanager, - self.update_surface_var_selection, - self.update_midpoint_var_selection, - self.update_interface_var_selection, + self.close_view, ) return self._ui diff --git a/quickview/ui/grid.py b/quickview/ui/grid.py index 79ffff6..35035a5 100644 --- a/quickview/ui/grid.py +++ b/quickview/ui/grid.py @@ -118,8 +118,10 @@ def __init__(self): 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] @@ -233,45 +235,14 @@ def set_manual_color_range(self, index, type, value): def revert_to_auto_color_range(self, index): self.viewmanager.revert_to_auto_color_range(index) - def close_view(self, index): - var = self.state.variables.pop(index) - origin = self.state.varorigin.pop(index) - self._cached_layout.pop(var) - self.state.dirty("variables") - self.state.dirty("varorigin") - self.viewmanager.close_view(var, index, self._cached_layout) - state = self.state - - # Find variable to unselect from the UI - if origin == 0: - # Find and clear surface display - if var in state.surface_vars: - var_index = state.surface_vars.index(var) - self.update_surface_var_selection(var_index, False) - elif origin == 1: - # Find and clear midpoints display - if var in state.midpoint_vars: - var_index = state.midpoint_vars.index(var) - self.update_midpoint_var_selection(var_index, False) - elif origin == 2: - # Find and clear interface display - if var in state.interface_vars: - var_index = state.interface_vars.index(var) - self.update_interface_var_selection(var_index, False) - def __init__( self, server, view_manager=None, - update_surface_var_selection=None, - update_midpoint_var_selection=None, - update_interface_var_selection=None, + close_view=None, ): self.server = server self.viewmanager = view_manager - self.update_surface_var_selection = update_surface_var_selection - self.update_midpoint_var_selection = update_midpoint_var_selection - self.update_interface_var_selection = update_interface_var_selection with grid.GridLayout( layout=("layout",), @@ -465,7 +436,7 @@ def __init__( with v2.VBtn( icon=True, style="color: white; background-color: rgba(255, 255, 255, 0.1);", - click=(self.close_view, "[idx]"), + click=(close_view, "[idx]"), classes="ma-0", v_bind="attrs", v_on="on", From 998ae444afb6c3ed2a883eeb359f71e075d873c6 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 16 Sep 2025 12:57:58 -0700 Subject: [PATCH 10/10] =?UTF-8?q?Bump=20version:=201.0.1=20=E2=86=92=201.0?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- quickview/__init__.py | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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/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/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": {