From 787f2883bb0ef6006f2d0d512eb93ca80fdcb026 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Wed, 12 Nov 2025 15:24:45 -0800 Subject: [PATCH 1/2] chore: removing unnecessary files from UI refactor --- src/e3sm_quickview/app.py | 567 +++++++- src/e3sm_quickview/app2.py | 533 ------- src/e3sm_quickview/interface.py | 826 ----------- src/e3sm_quickview/ui/collapsible.py | 27 - src/e3sm_quickview/ui/grid.py | 447 ------ src/e3sm_quickview/ui/projection_selection.py | 53 - src/e3sm_quickview/ui/slice_selection.py | 289 ---- src/e3sm_quickview/ui/toolbar.py | 305 ---- src/e3sm_quickview/ui/variable_selection.py | 105 -- src/e3sm_quickview/ui/view_settings.py | 213 --- src/e3sm_quickview/view_manager.py | 1245 +++++++---------- src/e3sm_quickview/view_manager2.py | 521 ------- 12 files changed, 1016 insertions(+), 4115 deletions(-) delete mode 100644 src/e3sm_quickview/app2.py delete mode 100644 src/e3sm_quickview/interface.py delete mode 100644 src/e3sm_quickview/ui/collapsible.py delete mode 100644 src/e3sm_quickview/ui/grid.py delete mode 100644 src/e3sm_quickview/ui/projection_selection.py delete mode 100644 src/e3sm_quickview/ui/slice_selection.py delete mode 100644 src/e3sm_quickview/ui/toolbar.py delete mode 100644 src/e3sm_quickview/ui/variable_selection.py delete mode 100644 src/e3sm_quickview/ui/view_settings.py delete mode 100644 src/e3sm_quickview/view_manager2.py diff --git a/src/e3sm_quickview/app.py b/src/e3sm_quickview/app.py index 59e1c70..36fdf2c 100644 --- a/src/e3sm_quickview/app.py +++ b/src/e3sm_quickview/app.py @@ -1,59 +1,532 @@ -import os +import asyncio import json -import argparse -import traceback +import datetime +import os + from pathlib import Path +from trame.app import TrameApp, asynchronous, file_upload +from trame.ui.vuetify3 import VAppLayout +from trame.widgets import vuetify3 as v3, client, html, dataclass, trame as tw, tauri +from trame.decorators import controller, change, trigger, life_cycle +from e3sm_quickview import module as qv_module +from e3sm_quickview.assets import ASSETS +from e3sm_quickview.components import doc, file_browser, css, toolbars, dialogs, drawers from e3sm_quickview.pipeline import EAMVisSource -from e3sm_quickview.interface import EAMApp +from e3sm_quickview.utils import compute, js, constants, cli +from e3sm_quickview.view_manager import ViewManager -def main(): - parser = argparse.ArgumentParser( - prog="eamapp.py", description="Trame based app for visualizing EAM data" - ) - parser.add_argument( - "-cf", "--conn", nargs="?", help="the nc file with connnectivity information" - ) - parser.add_argument("-df", "--data", help="the nc file with data/variables") - parser.add_argument("-sf", "--state", nargs="?", help="state file to be loaded") - parser.add_argument( - "-wd", "--workdir", help="working directory (to store session data)" +v3.enable_lab() + + +class EAMApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + + # Pre-load deferred widgets + dataclass.initialize(self.server) + self.server.enable_module(qv_module) + + # CLI + args = cli.configure_and_parse(self.server.cli) + + # Initial UI state + self.state.update( + { + "trame__title": "QuickView", + "trame__favicon": ASSETS.icon, + "is_tauri": False, + "animation_play": False, + # All available variables + "variables_listing": [], + # Selected variables to load + "variables_selected": [], + # Control 'Load Variables' button availability + "variables_loaded": False, + # Level controls + "midpoint_idx": 0, + "midpoints": [], + "interface_idx": 0, + "interfaces": [], + # Time controls + "time_idx": 0, + "timestamps": [], + # Fields summaries + "fields_avgs": {}, + } + ) + + # Data input + self.source = EAMVisSource() + + # Helpers + self.view_manager = ViewManager(self.server, self.source) + self.file_browser = file_browser.ParaViewFileBrowser( + self.server, + prefix="pv_files", + home=None if args.user_home else args.workdir, # can use current= + group="", + ) + + # Process CLI to pre-load data + if args.state is not None: + state_content = json.loads(Path(args.state).read_text()) + + async def wait_for_import(**_): + await self.import_state(state_content) + + self.ctrl.on_server_ready.add_task(wait_for_import) + elif args.data and args.conn: + self.file_browser.set_data_simulation(args.data) + self.file_browser.set_data_connectivity(args.conn) + self.ctrl.on_server_ready.add(self.file_browser.load_data_files) + + # Development setup + if self.server.hot_reload: + self.ctrl.on_server_reload.add(self._build_ui) + self.ctrl.on_server_reload.add(self.view_manager.refresh_ui) + + # GUI + self._build_ui() + + # ------------------------------------------------------------------------- + # Tauri adapter + # ------------------------------------------------------------------------- + + @life_cycle.server_ready + def _tauri_ready(self, **_): + os.write(1, f"tauri-server-port={self.server.port}\n".encode()) + + @life_cycle.client_connected + def _tauri_show(self, **_): + os.write(1, "tauri-client-ready\n".encode()) + + # ------------------------------------------------------------------------- + # UI definition + # ------------------------------------------------------------------------- + + def _build_ui(self, **_): + with VAppLayout(self.server, fill_height=True) as self.ui: + # Keyboard shortcut + with tw.MouseTrap( + ResetCamera=self.view_manager.reset_camera, + SizeAuto=(self.view_manager.apply_size, "[0]"), + Size1=(self.view_manager.apply_size, "[1]"), + Size2=(self.view_manager.apply_size, "[2]"), + Size3=(self.view_manager.apply_size, "[3]"), + Size4=(self.view_manager.apply_size, "[4]"), + Size6=(self.view_manager.apply_size, "[6]"), + SizeFlow=(self.view_manager.apply_size, "['flow']"), + ToolbarLayout=(self.toggle_toolbar, "['adjust-layout']"), + ToolbarCrop=(self.toggle_toolbar, "['adjust-databounds']"), + ToolbarSelect=(self.toggle_toolbar, "['select-slice-time']"), + ToolbarAnimation=(self.toggle_toolbar, "['animation-controls']"), + ToggleVariableSelection=(self.toggle_toolbar, "['select-fields']"), + RemoveAllToolbars=(self.toggle_toolbar), + ToggleGroups="layout_grouped = !layout_grouped", + ProjectionEquidistant="projection = ['Cyl. Equidistant']", + ProjectionRobinson="projection = ['Robinson']", + ProjectionMollweide="projection = ['Mollweide']", + ToggleViewLock="lock_views = !lock_views", + FileOpen=(self.toggle_toolbar, "['load-data']"), + SaveState="trigger('download_state')", + UploadState="utils.get('document').querySelector('#fileUpload').click()", + ToggleHelp="compact_drawer = !compact_drawer", + ) as mt: + mt.bind(["r"], "ResetCamera") + mt.bind(["alt+0", "0"], "SizeAuto") + mt.bind(["alt+1", "1"], "Size1") + mt.bind(["alt+2", "2"], "Size2") + mt.bind(["alt+3", "3"], "Size3") + mt.bind(["alt+4", "4"], "Size4") + mt.bind(["alt+6", "6"], "Size6") + mt.bind(["="], "SizeFlow") + + mt.bind("e", "ProjectionEquidistant") + mt.bind("b", "ProjectionRobinson") + mt.bind("m", "ProjectionMollweide") + + mt.bind("f", "FileOpen") + mt.bind("d", "SaveState") + mt.bind("u", "UploadState") + mt.bind("h", "ToggleHelp") + + mt.bind("l", "ToolbarLayout") + mt.bind("c", "ToolbarCrop") + mt.bind("s", "ToolbarSelect") + mt.bind("a", "ToolbarAnimation") + mt.bind("g", "ToggleGroups") + + mt.bind("v", "ToggleVariableSelection") + + mt.bind("space", "ToggleViewLock", stop_propagation=True) + + mt.bind("esc", "RemoveAllToolbars") + + # Native Dialogs + client.ClientTriggers(mounted="is_tauri = !!window.__TAURI__") + with tauri.Dialog() as dialog: + self.ctrl.save = dialog.save + + with v3.VLayout(): + drawers.Tools( + reset_camera=self.view_manager.reset_camera, + ) + + with v3.VMain(): + dialogs.FileOpen(self.file_browser) + dialogs.StateDownload() + drawers.FieldSelection(load_variables=self.data_load_variables) + + with v3.VContainer(classes="h-100 pa-0", fluid=True): + with client.SizeObserver("main_size"): + # Take space to push content below the fixed overlay + html.Div(style=("`height: ${top_padding}px`",)) + + # Fixed overlay for toolbars + with html.Div(style=css.TOOLBARS_FIXED_OVERLAY): + toolbars.Layout(apply_size=self.view_manager.apply_size) + toolbars.Cropping() + toolbars.DataSelection() + toolbars.Animation() + + # View of all the variables + client.ServerTemplate( + name=("active_layout", "auto_layout"), + v_if="variables_selected.length", + ) + + # Show documentation when no variable selected + with html.Div(v_if="!variables_selected.length"): + doc.LandingPage() + + # ------------------------------------------------------------------------- + # Derived properties + # ------------------------------------------------------------------------- + + @property + def selected_variables(self): + vars_per_type = {n: [] for n in "smi"} + for var in self.state.variables_selected: + type = var[0] + name = var[1:] + vars_per_type[type].append(name) + + return vars_per_type + + @property + def selected_variable_names(self): + # Remove var type (first char) + return [var[1:] for var in self.state.variables_selected] + + # ------------------------------------------------------------------------- + # Methods connected to UI + # ------------------------------------------------------------------------- + + @trigger("download_state") + @controller.set("download_state") + def download_state(self): + active_variables = self.selected_variables + state_content = {} + state_content["origin"] = { + "user": os.environ.get("USER", os.environ.get("USERNAME")), + "created": f"{datetime.datetime.now()}", + "comment": self.state.export_comment, + } + state_content["files"] = { + "simulation": str(Path(self.file_browser.get("data_simulation")).resolve()), + "connectivity": str( + Path(self.file_browser.get("data_connectivity")).resolve() + ), + } + state_content["variables-selection"] = self.state.variables_selected + state_content["layout"] = { + "aspect-ratio": self.state.aspect_ratio, + "grouped": self.state.layout_grouped, + "active": self.state.active_layout, + "tools": self.state.active_tools, + "help": not self.state.compact_drawer, + } + state_content["data-selection"] = { + k: self.state[k] + for k in [ + "time_idx", + "midpoint_idx", + "interface_idx", + "crop_longitude", + "crop_latitude", + "projection", + ] + } + views_to_export = state_content["views"] = [] + for view_type, var_names in active_variables.items(): + for var_name in var_names: + config = self.view_manager.get_view(var_name, view_type).config + views_to_export.append( + { + "type": view_type, + "name": var_name, + "config": { + # lut + "preset": config.preset, + "invert": config.invert, + "use_log_scale": config.use_log_scale, + # layout + "order": config.order, + "size": config.size, + "offset": config.offset, + "break_row": config.break_row, + # color range + "override_range": config.override_range, + "color_range": config.color_range, + "color_value_min": config.color_value_min, + "color_value_max": config.color_value_max, + }, + } + ) + + return json.dumps(state_content, indent=2) + + @change("upload_state_file") + def _on_import_state(self, upload_state_file, **_): + if upload_state_file is None: + return + + file_proxy = file_upload.ClientFile(upload_state_file) + state_content = json.loads(file_proxy.content) + self.import_state(state_content) + + @controller.set("import_state") + def import_state(self, state_content): + asynchronous.create_task(self._import_state(state_content)) + + async def _import_state(self, state_content): + # Files + self.file_browser.set_data_simulation(state_content["files"]["simulation"]) + self.file_browser.set_data_connectivity(state_content["files"]["connectivity"]) + await self.data_loading_open( + self.file_browser.get("data_simulation"), + self.file_browser.get("data_connectivity"), + ) + + # Load variables + self.state.variables_selected = state_content["variables-selection"] + self.state.update(state_content["data-selection"]) + await self._data_load_variables() + self.state.variables_loaded = True + + # Update view states + for view_state in state_content["views"]: + view_type = view_state["type"] + var_name = view_state["name"] + config = self.view_manager.get_view(var_name, view_type).config + config.update(**view_state["config"]) + + # Update layout + self.state.aspect_ratio = state_content["layout"]["aspect-ratio"] + self.state.layout_grouped = state_content["layout"]["grouped"] + self.state.active_layout = state_content["layout"]["active"] + self.state.active_tools = state_content["layout"]["tools"] + self.state.compact_drawer = not state_content["layout"]["help"] + + # Update filebrowser state + with self.state: + self.file_browser.set("state_loading", False) + + @controller.add_task("file_selection_load") + async def data_loading_open(self, simulation, connectivity): + # Reset state + self.state.variables_selected = [] + self.state.variables_loaded = False + self.state.midpoint_idx = 0 + self.state.midpoints = [] + self.state.interface_idx = 0 + self.state.interfaces = [] + self.state.time_idx = 0 + self.state.timestamps = [] + + await asyncio.sleep(0.1) + self.source.Update( + data_file=simulation, + conn_file=connectivity, + ) + + self.file_browser.loading_completed(self.source.valid) + + if self.source.valid: + with self.state as s: + s.active_tools = list( + set( + ( + "select-fields", + *(tool for tool in s.active_tools if tool != "load-data"), + ) + ) + ) + + self.state.variables_filter = "" + self.state.variables_listing = [ + *( + {"name": name, "type": "surface", "id": f"s{name}"} + for name in self.source.surface_vars + ), + *( + {"name": name, "type": "interface", "id": f"i{name}"} + for name in self.source.interface_vars + ), + *( + {"name": name, "type": "midpoint", "id": f"m{name}"} + for name in self.source.midpoint_vars + ), + ] + + # Update Layer/Time values and ui layout + n_cols = 0 + available_tracks = [] + for name in ["midpoints", "interfaces", "timestamps"]: + values = getattr(self.source, name) + self.state[name] = values + + if len(values) > 1: + n_cols += 1 + available_tracks.append(constants.TRACK_ENTRIES[name]) + + self.state.toolbar_slider_cols = 12 / n_cols if n_cols else 12 + self.state.animation_tracks = available_tracks + self.state.animation_track = ( + self.state.animation_tracks[0]["value"] + if available_tracks + else None + ) + + @controller.set("file_selection_cancel") + def data_loading_hide(self): + self.state.active_tools = [ + tool for tool in self.state.active_tools if tool != "load-data" + ] + + def data_load_variables(self): + asynchronous.create_task(self._data_load_variables()) + + async def _data_load_variables(self): + """Called at 'Load Variables' button click""" + vars_to_show = self.selected_variables + + self.source.LoadVariables( + vars_to_show["s"], # surfaces + vars_to_show["m"], # midpoints + vars_to_show["i"], # interfaces + ) + + # Trigger source update + compute avg + with self.state: + self.state.variables_loaded = True + await self.server.network_completion + + # Update views in layout + with self.state: + self.view_manager.build_auto_layout(vars_to_show) + await self.server.network_completion + + # Reset camera after yield + await asyncio.sleep(0.1) + self.view_manager.reset_camera() + + @change("layout_grouped") + def _on_layout_change(self, **_): + vars_to_show = self.selected_variables + + if any(vars_to_show.values()): + self.view_manager.build_auto_layout(vars_to_show) + + @change("projection") + async def _on_projection(self, projection, **_): + proj_str = projection[0] + self.source.UpdateProjection(proj_str) + self.source.UpdatePipeline() + self.view_manager.reset_camera() + + # Hack to force reset_camera for "cyl mode" + # => may not be needed if we switch to rca + if " " in proj_str: + for _ in range(2): + await asyncio.sleep(0.1) + self.view_manager.reset_camera() + + @change("active_tools") + def _on_toolbar_change(self, active_tools, **_): + top_padding = 0 + for name in active_tools: + top_padding += toolbars.SIZES.get(name, 0) + + self.state.top_padding = top_padding + + @change( + "variables_loaded", + "time_idx", + "midpoint_idx", + "interface_idx", + "crop_longitude", + "crop_latitude", + "projection", ) - args, xargs = parser.parse_known_args() - - data_file = args.data - state_file = args.state - work_dir = args.workdir - conn_file = args.conn - - # ValidateArguments(conn_file, data_file, state_file, work_dir) - - # if args.conn is None: - # conn_file = os.path.join( - # os.path.dirname(__file__), "quickview", "data", "connectivity.nc" - # ) - - if work_dir is None: - work_dir = str(os.getcwd()) - - source = EAMVisSource() - state = None - try: - if state_file is not None: - state = json.loads(Path(state_file).read_text()) - data_file = state["data_file"] - conn_file = state["conn_file"] - source.Update( - data_file=data_file, - conn_file=conn_file, + def _on_time_change( + self, + variables_loaded, + time_idx, + timestamps, + midpoint_idx, + interface_idx, + crop_longitude, + crop_latitude, + projection, + **_, + ): + if not variables_loaded: + return + + time_value = timestamps[time_idx] if len(timestamps) else 0.0 + self.source.UpdateLev(midpoint_idx, interface_idx) + self.source.ApplyClipping(crop_longitude, crop_latitude) + self.source.UpdateProjection(projection[0]) + self.source.UpdateTimeStep(time_idx) + self.source.UpdatePipeline(time_value) + + self.view_manager.update_color_range() + self.view_manager.render() + + # Update avg computation + # Get area variable to calculate weighted average + data = self.source.views["atmosphere_data"] + self.state.fields_avgs = compute.extract_avgs( + data, self.selected_variable_names ) - app = EAMApp(source, workdir=work_dir, initstate=state) - app.start() - except Exception as e: - print("Problem : ", e) - traceback.print_exc() + + def toggle_toolbar(self, toolbar_name=None): + if toolbar_name is None: + self.state.compact_drawer = True + self.state.active_tools = [] + return + + if toolbar_name in self.state.active_tools: + # remove + self.state.active_tools = [ + n for n in self.state.active_tools if n != toolbar_name + ] + else: + # add + self.state.active_tools.append(toolbar_name) + self.state.dirty("active_tools") + + +# ------------------------------------------------------------------------- +# Standalone execution +# ------------------------------------------------------------------------- +def main(): + app = EAMApp() + app.server.start() if __name__ == "__main__": diff --git a/src/e3sm_quickview/app2.py b/src/e3sm_quickview/app2.py deleted file mode 100644 index 26960f8..0000000 --- a/src/e3sm_quickview/app2.py +++ /dev/null @@ -1,533 +0,0 @@ -import asyncio -import json -import datetime -import os - -from pathlib import Path - -from trame.app import TrameApp, asynchronous, file_upload -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3, client, html, dataclass, trame as tw, tauri -from trame.decorators import controller, change, trigger, life_cycle - -from e3sm_quickview import module as qv_module -from e3sm_quickview.assets import ASSETS -from e3sm_quickview.components import doc, file_browser, css, toolbars, dialogs, drawers -from e3sm_quickview.pipeline import EAMVisSource -from e3sm_quickview.utils import compute, js, constants, cli -from e3sm_quickview.view_manager2 import ViewManager - - -v3.enable_lab() - - -class EAMApp(TrameApp): - def __init__(self, server=None): - super().__init__(server) - - # Pre-load deferred widgets - dataclass.initialize(self.server) - self.server.enable_module(qv_module) - - # CLI - args = cli.configure_and_parse(self.server.cli) - - # Initial UI state - self.state.update( - { - "trame__title": "QuickView", - "trame__favicon": ASSETS.icon, - "is_tauri": False, - "animation_play": False, - # All available variables - "variables_listing": [], - # Selected variables to load - "variables_selected": [], - # Control 'Load Variables' button availability - "variables_loaded": False, - # Level controls - "midpoint_idx": 0, - "midpoints": [], - "interface_idx": 0, - "interfaces": [], - # Time controls - "time_idx": 0, - "timestamps": [], - # Fields summaries - "fields_avgs": {}, - } - ) - - # Data input - self.source = EAMVisSource() - - # Helpers - self.view_manager = ViewManager(self.server, self.source) - self.file_browser = file_browser.ParaViewFileBrowser( - self.server, - prefix="pv_files", - home=None if args.user_home else args.workdir, # can use current= - group="", - ) - - # Process CLI to pre-load data - if args.state is not None: - state_content = json.loads(Path(args.state).read_text()) - - async def wait_for_import(**_): - await self.import_state(state_content) - - self.ctrl.on_server_ready.add_task(wait_for_import) - elif args.data and args.conn: - self.file_browser.set_data_simulation(args.data) - self.file_browser.set_data_connectivity(args.conn) - self.ctrl.on_server_ready.add(self.file_browser.load_data_files) - - # Development setup - if self.server.hot_reload: - self.ctrl.on_server_reload.add(self._build_ui) - self.ctrl.on_server_reload.add(self.view_manager.refresh_ui) - - # GUI - self._build_ui() - - # ------------------------------------------------------------------------- - # Tauri adapter - # ------------------------------------------------------------------------- - - @life_cycle.server_ready - def _tauri_ready(self, **_): - os.write(1, f"tauri-server-port={self.server.port}\n".encode()) - - @life_cycle.client_connected - def _tauri_show(self, **_): - os.write(1, "tauri-client-ready\n".encode()) - - # ------------------------------------------------------------------------- - # UI definition - # ------------------------------------------------------------------------- - - def _build_ui(self, **_): - with VAppLayout(self.server, fill_height=True) as self.ui: - # Keyboard shortcut - with tw.MouseTrap( - ResetCamera=self.view_manager.reset_camera, - SizeAuto=(self.view_manager.apply_size, "[0]"), - Size1=(self.view_manager.apply_size, "[1]"), - Size2=(self.view_manager.apply_size, "[2]"), - Size3=(self.view_manager.apply_size, "[3]"), - Size4=(self.view_manager.apply_size, "[4]"), - Size6=(self.view_manager.apply_size, "[6]"), - SizeFlow=(self.view_manager.apply_size, "['flow']"), - ToolbarLayout=(self.toggle_toolbar, "['adjust-layout']"), - ToolbarCrop=(self.toggle_toolbar, "['adjust-databounds']"), - ToolbarSelect=(self.toggle_toolbar, "['select-slice-time']"), - ToolbarAnimation=(self.toggle_toolbar, "['animation-controls']"), - ToggleVariableSelection=(self.toggle_toolbar, "['select-fields']"), - RemoveAllToolbars=(self.toggle_toolbar), - ToggleGroups="layout_grouped = !layout_grouped", - ProjectionEquidistant="projection = ['Cyl. Equidistant']", - ProjectionRobinson="projection = ['Robinson']", - ProjectionMollweide="projection = ['Mollweide']", - ToggleViewLock="lock_views = !lock_views", - FileOpen=(self.toggle_toolbar, "['load-data']"), - SaveState="trigger('download_state')", - UploadState="utils.get('document').querySelector('#fileUpload').click()", - ToggleHelp="compact_drawer = !compact_drawer", - ) as mt: - mt.bind(["r"], "ResetCamera") - mt.bind(["alt+0", "0"], "SizeAuto") - mt.bind(["alt+1", "1"], "Size1") - mt.bind(["alt+2", "2"], "Size2") - mt.bind(["alt+3", "3"], "Size3") - mt.bind(["alt+4", "4"], "Size4") - mt.bind(["alt+6", "6"], "Size6") - mt.bind(["="], "SizeFlow") - - mt.bind("e", "ProjectionEquidistant") - mt.bind("b", "ProjectionRobinson") - mt.bind("m", "ProjectionMollweide") - - mt.bind("f", "FileOpen") - mt.bind("d", "SaveState") - mt.bind("u", "UploadState") - mt.bind("h", "ToggleHelp") - - mt.bind("l", "ToolbarLayout") - mt.bind("c", "ToolbarCrop") - mt.bind("s", "ToolbarSelect") - mt.bind("a", "ToolbarAnimation") - mt.bind("g", "ToggleGroups") - - mt.bind("v", "ToggleVariableSelection") - - mt.bind("space", "ToggleViewLock", stop_propagation=True) - - mt.bind("esc", "RemoveAllToolbars") - - # Native Dialogs - client.ClientTriggers(mounted="is_tauri = !!window.__TAURI__") - with tauri.Dialog() as dialog: - self.ctrl.save = dialog.save - - with v3.VLayout(): - drawers.Tools( - reset_camera=self.view_manager.reset_camera, - ) - - with v3.VMain(): - dialogs.FileOpen(self.file_browser) - dialogs.StateDownload() - drawers.FieldSelection(load_variables=self.data_load_variables) - - with v3.VContainer(classes="h-100 pa-0", fluid=True): - with client.SizeObserver("main_size"): - # Take space to push content below the fixed overlay - html.Div(style=("`height: ${top_padding}px`",)) - - # Fixed overlay for toolbars - with html.Div(style=css.TOOLBARS_FIXED_OVERLAY): - toolbars.Layout(apply_size=self.view_manager.apply_size) - toolbars.Cropping() - toolbars.DataSelection() - toolbars.Animation() - - # View of all the variables - client.ServerTemplate( - name=("active_layout", "auto_layout"), - v_if="variables_selected.length", - ) - - # Show documentation when no variable selected - with html.Div(v_if="!variables_selected.length"): - doc.LandingPage() - - # ------------------------------------------------------------------------- - # Derived properties - # ------------------------------------------------------------------------- - - @property - def selected_variables(self): - vars_per_type = {n: [] for n in "smi"} - for var in self.state.variables_selected: - type = var[0] - name = var[1:] - vars_per_type[type].append(name) - - return vars_per_type - - @property - def selected_variable_names(self): - # Remove var type (first char) - return [var[1:] for var in self.state.variables_selected] - - # ------------------------------------------------------------------------- - # Methods connected to UI - # ------------------------------------------------------------------------- - - @trigger("download_state") - @controller.set("download_state") - def download_state(self): - active_variables = self.selected_variables - state_content = {} - state_content["origin"] = { - "user": os.environ.get("USER", os.environ.get("USERNAME")), - "created": f"{datetime.datetime.now()}", - "comment": self.state.export_comment, - } - state_content["files"] = { - "simulation": str(Path(self.file_browser.get("data_simulation")).resolve()), - "connectivity": str( - Path(self.file_browser.get("data_connectivity")).resolve() - ), - } - state_content["variables-selection"] = self.state.variables_selected - state_content["layout"] = { - "aspect-ratio": self.state.aspect_ratio, - "grouped": self.state.layout_grouped, - "active": self.state.active_layout, - "tools": self.state.active_tools, - "help": not self.state.compact_drawer, - } - state_content["data-selection"] = { - k: self.state[k] - for k in [ - "time_idx", - "midpoint_idx", - "interface_idx", - "crop_longitude", - "crop_latitude", - "projection", - ] - } - views_to_export = state_content["views"] = [] - for view_type, var_names in active_variables.items(): - for var_name in var_names: - config = self.view_manager.get_view(var_name, view_type).config - views_to_export.append( - { - "type": view_type, - "name": var_name, - "config": { - # lut - "preset": config.preset, - "invert": config.invert, - "use_log_scale": config.use_log_scale, - # layout - "order": config.order, - "size": config.size, - "offset": config.offset, - "break_row": config.break_row, - # color range - "override_range": config.override_range, - "color_range": config.color_range, - "color_value_min": config.color_value_min, - "color_value_max": config.color_value_max, - }, - } - ) - - return json.dumps(state_content, indent=2) - - @change("upload_state_file") - def _on_import_state(self, upload_state_file, **_): - if upload_state_file is None: - return - - file_proxy = file_upload.ClientFile(upload_state_file) - state_content = json.loads(file_proxy.content) - self.import_state(state_content) - - @controller.set("import_state") - def import_state(self, state_content): - asynchronous.create_task(self._import_state(state_content)) - - async def _import_state(self, state_content): - # Files - self.file_browser.set_data_simulation(state_content["files"]["simulation"]) - self.file_browser.set_data_connectivity(state_content["files"]["connectivity"]) - await self.data_loading_open( - self.file_browser.get("data_simulation"), - self.file_browser.get("data_connectivity"), - ) - - # Load variables - self.state.variables_selected = state_content["variables-selection"] - self.state.update(state_content["data-selection"]) - await self._data_load_variables() - self.state.variables_loaded = True - - # Update view states - for view_state in state_content["views"]: - view_type = view_state["type"] - var_name = view_state["name"] - config = self.view_manager.get_view(var_name, view_type).config - config.update(**view_state["config"]) - - # Update layout - self.state.aspect_ratio = state_content["layout"]["aspect-ratio"] - self.state.layout_grouped = state_content["layout"]["grouped"] - self.state.active_layout = state_content["layout"]["active"] - self.state.active_tools = state_content["layout"]["tools"] - self.state.compact_drawer = not state_content["layout"]["help"] - - # Update filebrowser state - with self.state: - self.file_browser.set("state_loading", False) - - @controller.add_task("file_selection_load") - async def data_loading_open(self, simulation, connectivity): - # Reset state - self.state.variables_selected = [] - self.state.variables_loaded = False - self.state.midpoint_idx = 0 - self.state.midpoints = [] - self.state.interface_idx = 0 - self.state.interfaces = [] - self.state.time_idx = 0 - self.state.timestamps = [] - - await asyncio.sleep(0.1) - self.source.Update( - data_file=simulation, - conn_file=connectivity, - ) - - self.file_browser.loading_completed(self.source.valid) - - if self.source.valid: - with self.state as s: - s.active_tools = list( - set( - ( - "select-fields", - *(tool for tool in s.active_tools if tool != "load-data"), - ) - ) - ) - - self.state.variables_filter = "" - self.state.variables_listing = [ - *( - {"name": name, "type": "surface", "id": f"s{name}"} - for name in self.source.surface_vars - ), - *( - {"name": name, "type": "interface", "id": f"i{name}"} - for name in self.source.interface_vars - ), - *( - {"name": name, "type": "midpoint", "id": f"m{name}"} - for name in self.source.midpoint_vars - ), - ] - - # Update Layer/Time values and ui layout - n_cols = 0 - available_tracks = [] - for name in ["midpoints", "interfaces", "timestamps"]: - values = getattr(self.source, name) - self.state[name] = values - - if len(values) > 1: - n_cols += 1 - available_tracks.append(constants.TRACK_ENTRIES[name]) - - self.state.toolbar_slider_cols = 12 / n_cols if n_cols else 12 - self.state.animation_tracks = available_tracks - self.state.animation_track = ( - self.state.animation_tracks[0]["value"] - if available_tracks - else None - ) - - @controller.set("file_selection_cancel") - def data_loading_hide(self): - self.state.active_tools = [ - tool for tool in self.state.active_tools if tool != "load-data" - ] - - def data_load_variables(self): - asynchronous.create_task(self._data_load_variables()) - - async def _data_load_variables(self): - """Called at 'Load Variables' button click""" - vars_to_show = self.selected_variables - - self.source.LoadVariables( - vars_to_show["s"], # surfaces - vars_to_show["m"], # midpoints - vars_to_show["i"], # interfaces - ) - - # Trigger source update + compute avg - with self.state: - self.state.variables_loaded = True - await self.server.network_completion - - # Update views in layout - with self.state: - self.view_manager.build_auto_layout(vars_to_show) - await self.server.network_completion - - # Reset camera after yield - await asyncio.sleep(0.1) - self.view_manager.reset_camera() - - @change("layout_grouped") - def _on_layout_change(self, **_): - vars_to_show = self.selected_variables - - if any(vars_to_show.values()): - self.view_manager.build_auto_layout(vars_to_show) - - @change("projection") - async def _on_projection(self, projection, **_): - proj_str = projection[0] - self.source.UpdateProjection(proj_str) - self.source.UpdatePipeline() - self.view_manager.reset_camera() - - # Hack to force reset_camera for "cyl mode" - # => may not be needed if we switch to rca - if " " in proj_str: - for _ in range(2): - await asyncio.sleep(0.1) - self.view_manager.reset_camera() - - @change("active_tools") - def _on_toolbar_change(self, active_tools, **_): - top_padding = 0 - for name in active_tools: - top_padding += toolbars.SIZES.get(name, 0) - - self.state.top_padding = top_padding - - @change( - "variables_loaded", - "time_idx", - "midpoint_idx", - "interface_idx", - "crop_longitude", - "crop_latitude", - "projection", - ) - def _on_time_change( - self, - variables_loaded, - time_idx, - timestamps, - midpoint_idx, - interface_idx, - crop_longitude, - crop_latitude, - projection, - **_, - ): - if not variables_loaded: - return - - time_value = timestamps[time_idx] if len(timestamps) else 0.0 - self.source.UpdateLev(midpoint_idx, interface_idx) - self.source.ApplyClipping(crop_longitude, crop_latitude) - self.source.UpdateProjection(projection[0]) - self.source.UpdateTimeStep(time_idx) - self.source.UpdatePipeline(time_value) - - self.view_manager.update_color_range() - self.view_manager.render() - - # Update avg computation - # Get area variable to calculate weighted average - data = self.source.views["atmosphere_data"] - self.state.fields_avgs = compute.extract_avgs( - data, self.selected_variable_names - ) - - def toggle_toolbar(self, toolbar_name=None): - if toolbar_name is None: - self.state.compact_drawer = True - self.state.active_tools = [] - return - - if toolbar_name in self.state.active_tools: - # remove - self.state.active_tools = [ - n for n in self.state.active_tools if n != toolbar_name - ] - else: - # add - self.state.active_tools.append(toolbar_name) - self.state.dirty("active_tools") - - -# ------------------------------------------------------------------------- -# Standalone execution -# ------------------------------------------------------------------------- -def main(): - app = EAMApp() - app.server.start() - - -if __name__ == "__main__": - main() diff --git a/src/e3sm_quickview/interface.py b/src/e3sm_quickview/interface.py deleted file mode 100644 index 82ada2e..0000000 --- a/src/e3sm_quickview/interface.py +++ /dev/null @@ -1,826 +0,0 @@ -import os -import json -import base64 -import numpy as np -import xml.etree.ElementTree as ET - -from pathlib import Path -from typing import Union - -from trame.app import TrameApp -from trame.decorators import life_cycle, trigger, change -from trame.ui.vuetify import SinglePageWithDrawerLayout - -from trame.widgets import vuetify as v2, client -from trame.widgets import paraview as pvWidgets - -from trame_server.core import Server - -from e3sm_quickview.pipeline import EAMVisSource - -from e3sm_quickview.ui.slice_selection import SliceSelection -from e3sm_quickview.ui.projection_selection import ProjectionSelection -from e3sm_quickview.ui.variable_selection import VariableSelection -from e3sm_quickview.ui.toolbar import Toolbar -from e3sm_quickview.ui.grid import Grid - -# Build color cache here -from e3sm_quickview.view_manager import build_color_information -from e3sm_quickview.view_manager import ViewManager - -from paraview.simple import ImportPresets, GetLookupTableNames - -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 -) - -# ----------------------------------------------------------------------------- -# Load logo image as base64 -# ----------------------------------------------------------------------------- - - -def get_logo_base64(): - """Load the QuickView logo as base64 encoded string.""" - logo_path = Path(__file__).parent / "assets" / "quick-view-text.png" - if logo_path.exists(): - with open(logo_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - return "" # Return empty string if logo not found - - -# Cache the logo at module load time -LOGO_BASE64 = get_logo_base64() - -# ----------------------------------------------------------------------------- -# trame setup -# ----------------------------------------------------------------------------- - -noncvd = [ - { - "text": "Rainbow Desat.", - "value": "Rainbow Desaturated", - }, - { - "text": "Yellow-Gray-Blue", - "value": "Yellow - Gray - Blue", - }, - { - "text": "Blue Orange (div.)", - "value": "Blue Orange (divergent)", - }, - { - "text": "Cool to Warm (Ext.)", - "value": "Cool to Warm (Extended)", - }, - { - "text": "Black-Body Rad.", - "value": "Black-Body Radiation", - }, - { - "text": "Blue-Green-Orange", - "value": "Blue - Green - Orange", - }, -] - -cvd = [ - { - "text": "Inferno (matplotlib)", - "value": "Inferno (matplotlib)", - }, - { - "text": "Viridis (matplotlib)", - "value": "Viridis (matplotlib)", - }, -] - -save_state_keys = [ - # Data files - "data_file", - "conn_file", - # Data slice related variables - "tstamp", - "midpoint", - "interface", - # Latitude/Longitude clipping - "cliplat", - "cliplong", - # Projection and centering - "projection", - "center", - # Color map related variables - "variables", - "varcolor", - "uselogscale", - "invert", - "varmin", - "varmax", - "override_range", # Track manual color range override per variable - "varaverage", # Track computed average per variable - # Color options from toolbar - "use_cvd_colors", - "use_standard_colors", - # Grid layout - "layout", -] - - -try: - existing = GetLookupTableNames() - presdir = os.path.join(os.path.dirname(__file__), "presets") - presets = os.listdir(path=presdir) - for preset in presets: - prespath = os.path.abspath(os.path.join(presdir, preset)) - if os.path.isfile(prespath): - name = ET.parse(prespath).getroot()[0].attrib["name"] - if name not in existing: - print("Importing non existing preset ", name) - ImportPresets(prespath) - cvd.append({"text": name.title(), "value": name}) -except Exception as e: - print("Error loading presets :", e) - - -class EAMApp(TrameApp): - def __init__( - self, - source: EAMVisSource = None, - initserver: Union[Server, str] = None, - initstate: dict = None, - workdir: Union[str, Path] = None, - ) -> None: - super().__init__(initserver, client_type="vue2") - - self._ui = None - self._cached_layout = {} # Cache for layout positions by variable name - - pvWidgets.initialize(self.server) - - self.source = source - 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 "" - - # Initialize slice selection state variables with defaults - state.midpoint = 0 # Selected midpoint index - state.interface = 0 # Selected interface index - state.tstamp = 0 - state.timesteps = [] - state.midpoints = [] # Array of midpoint values - state.interfaces = [] # Array of interface values - state.cliplong = [-180.0, 180.0] - state.cliplat = [-90.0, 90.0] - - # Initialize variable lists - state.surface_vars = [] - state.midpoint_vars = [] - state.interface_vars = [] - state.surface_vars_state = [] - state.midpoint_vars_state = [] - state.interface_vars_state = [] - - # Initialize other required state variables - state.pipeline_valid = False - state.extents = [-180.0, 180.0, -90.0, 90.0] - state.variables = [] - state.colormaps = noncvd # Initialize with default colormaps - state.use_cvd_colors = False - state.use_standard_colors = True - - # Initialize UI panel visibility states - state.show_surface_vars = False - state.show_midpoint_vars = False - state.show_interface_vars = False - state.show_slice = True # Show slice selection by default - state.show_projection = False - - # Only update from source if it's valid - if source.valid: - self.update_state_from_source() - - self.ind_surface = None - self.ind_midpoint = None - self.ind_interface = None - state.views = [] - # state.projection = "Cyl. Equidistant" - # state.cliplong = [self.source.extents[0], self.source.extents[1]], - # state.cliplat = [self.source.extents[2], self.source.extents[3]], - # Removed cmaps initialization - now handled by toolbar toggle buttons - state.layout = [] - state.variables = [] - state.varcolor = [] - state.uselogscale = [] - state.invert = [] - state.varmin = [] - state.varmax = [] - state.override_range = [] - state.colorbar_images = [] - state.varaverage = [] - - state.probe_enabled = False - state.probe_location = [] # Default probe - - ctrl = self.ctrl - ctrl.view_update = self.viewmanager.render_all_views - ctrl.view_reset_camera = self.viewmanager.reset_camera - ctrl.on_server_ready.add(ctrl.view_update) - - self.server.trigger_name(ctrl.view_reset_camera) - - state.colormaps = noncvd - - self.state.pipeline_valid = source.valid - # User controlled state variables - if initstate is None: - self.init_app_configuration() - else: - self.update_state_from_config(initstate) - - @life_cycle.server_ready - def _tauri_ready(self, **_): - os.write(1, f"tauri-server-port={self.server.port}\n".encode()) - - @life_cycle.client_connected - def _tauri_show(self, **_): - os.write(1, "tauri-client-ready\n".encode()) - - @change("pipeline_valid") - def _on_change_pipeline_valid(self, pipeline_valid, **kwargs): - if not pipeline_valid: - source = self.source - state = self.state - state.surface_vars_state = [False] * len(source.surface_vars) - state.midpoint_vars_state = [False] * len(source.midpoint_vars) - state.interface_vars_state = [False] * len(source.interface_vars) - - def init_app_configuration(self): - source = self.source - with self.state as state: - state.midpoint = 0 - state.interface = 0 - state.tstamp = 0 - state.surface_vars_state = [False] * len(source.surface_vars) - state.midpoint_vars_state = [False] * len(source.midpoint_vars) - state.interface_vars_state = [False] * len(source.interface_vars) - self.surface_vars_state = np.array([False] * len(source.surface_vars)) - self.midpoint_vars_state = np.array([False] * len(source.midpoint_vars)) - self.interface_vars_state = np.array([False] * len(source.interface_vars)) - - def update_state_from_source(self): - source = self.source - with self.state as state: - state.timesteps = source.timestamps - state.midpoints = source.midpoints - state.interfaces = source.interfaces - state.extents = list(source.extents) - state.surface_vars = source.surface_vars - state.interface_vars = source.interface_vars - state.midpoint_vars = source.midpoint_vars - state.pipeline_valid = source.valid - # Update clipping ranges from source extents - if source.extents and len(source.extents) >= 4: - state.cliplong = [source.extents[0], source.extents[1]] - state.cliplat = [source.extents[2], source.extents[3]] - - def update_state_from_config(self, initstate): - source = self.source - self.state.update(initstate) - - with self.state as state: - state.surface_vars = source.surface_vars - state.interface_vars = source.interface_vars - state.midpoint_vars = source.midpoint_vars - selection = state.variables - selection_surface = np.isin(state.surface_vars, selection).tolist() - selection_midpoint = np.isin(state.midpoint_vars, selection).tolist() - selection_interface = np.isin(state.interface_vars, selection).tolist() - state.surface_vars_state = selection_surface - state.midpoint_vars_state = selection_midpoint - state.interface_vars_state = selection_interface - self.update_available_color_maps() - self.surface_vars_state = np.array(selection_surface) - self.midpoint_vars_state = np.array(selection_midpoint) - self.interface_vars_state = np.array(selection_interface) - - self.viewmanager.registry = build_color_information(initstate) - self.load_variables(use_cached_layout=True) - - @trigger("layout_changed") - def on_layout_changed_trigger(self, layout, **kwargs): - # There should always be a 1:1 correspondence - # between the layout and the variables - assert len(layout) == len(self.state.variables) - # Cache the layout data with variable names as keys for easier lookup - self._cached_layout = {} - for idx, item in enumerate(layout): - if idx < len(self.state.variables): - var_name = self.state.variables[idx] - self._cached_layout[var_name] = { - "i": idx, - "x": item.get("x", 0), - "y": item.get("y", 0), - "w": item.get("w", 4), - "h": item.get("h", 3), - } - - def generate_state(self): - # Force state synchronization - self.state.flush() - - all = self.state.to_dict() - to_export = {k: all[k] for k in save_state_keys} - - # Convert cached layout back to array format for saving - if self._cached_layout and hasattr(self.state, "variables"): - layout_array = [] - for idx, var_name in enumerate(self.state.variables): - if var_name in self._cached_layout: - pos = self._cached_layout[var_name] - layout_array.append( - { - "x": pos["x"], - "y": pos["y"], - "w": pos["w"], - "h": pos["h"], - "i": idx, - } - ) - if layout_array: - to_export["layout"] = layout_array - - return to_export - - def load_state(self, state_file): - self._on_change_pipeline_valid(False) - self.viewmanager._on_change_pipeline_valid(False) - self.state.pipeline_valid = False - from_state = json.loads(Path(state_file).read_text()) - data_file = from_state["data_file"] - conn_file = from_state["conn_file"] - # Convert loaded layout to variable-name-based cache - self._cached_layout = {} - if ( - "layout" in from_state - and from_state["layout"] - and "variables" in from_state - ): - for item in from_state["layout"]: - if isinstance(item, dict) and "i" in item: - idx = item["i"] - if idx < len(from_state["variables"]): - var_name = from_state["variables"][idx] - self._cached_layout[var_name] = { - "x": item.get("x", 0), - "y": item.get("y", 0), - "w": item.get("w", 4), - "h": item.get("h", 3), - } - is_valid = self.source.Update( - data_file=data_file, - conn_file=conn_file, - ) - if is_valid: - self.update_state_from_source() - self.update_state_from_config(from_state) - self.state.pipeline_valid = is_valid - - self.ctrl.view_reset_camera() - - def load_data(self): - self._on_change_pipeline_valid(False) - self.viewmanager._on_change_pipeline_valid(False) - state = self.state - # Update returns True/False for validity - # force_reload=True since user explicitly clicked Load Files button - is_valid = self.source.Update( - data_file=self.state.data_file, - conn_file=self.state.conn_file, - force_reload=True, - ) - - # Update state based on pipeline validity - if is_valid: - self.update_state_from_source() - self.init_app_configuration() - else: - # Keep the defaults that were set in __init__ - # but ensure arrays are empty if pipeline - state.timesteps = [] - state.midpoints = [] - state.interfaces = [] - - state.pipeline_valid = is_valid - - def get_default_colormap(self): - """ - Determine the default colormap based on availability. - Returns 'Batlow' if CVD colormaps are available, - 'Cool to Warm (Extended)' for non-CVD, or falls back to first available. - """ - if cvd: # CVD colormaps are available - # Look for Batlow in current colormaps - for cmap in self.state.colormaps: - if cmap["value"].lower() == "batlow": - return cmap["value"] - else: # Only non-CVD colormaps available - # Look for Cool to Warm (Extended) - for cmap in self.state.colormaps: - if cmap["value"] == "Cool to Warm (Extended)": - return cmap["value"] - - # Fallback to first available colormap - return ( - self.state.colormaps[0]["value"] - if self.state.colormaps - else "Cool to Warm (Extended)" - ) - - def load_variables(self, use_cached_layout=False): - surf = [] - mid = [] - intf = [] - # Use the original unfiltered lists from source and the full selection state - if len(self.source.surface_vars) > 0: - v_surf = np.array(self.source.surface_vars) - f_surf = ( - self.surface_vars_state - ) # Use the full state array, not the filtered one - if len(v_surf) == len(f_surf): # Ensure arrays are same length - surf = v_surf[f_surf].tolist() - if len(self.source.midpoint_vars) > 0: - v_mid = np.array(self.source.midpoint_vars) - f_mid = self.midpoint_vars_state # Use the full state array - if len(v_mid) == len(f_mid): # Ensure arrays are same length - mid = v_mid[f_mid].tolist() - if len(self.source.interface_vars) > 0: - v_intf = np.array(self.source.interface_vars) - f_intf = self.interface_vars_state # Use the full state array - if len(v_intf) == len(f_intf): # Ensure arrays are same length - intf = v_intf[f_intf].tolist() - - print("Load surf", surf) - print("Load mid", mid) - print("Load intf", intf) - self.source.LoadVariables(surf, mid, intf) - - vars = surf + mid + intf - varorigin = [0] * len(surf) + [1] * len(mid) + [2] * len(intf) - - # Tracking variables to control camera and color properties - with self.state as state: - state.variables = vars - state.varorigin = varorigin - - # Initialize arrays that are always needed regardless of cache status - # Color configuration arrays will be populated by ViewContext via sync_color_config_to_state - if not use_cached_layout: - # Initialize empty arrays - ViewContext will populate them through sync - state.varcolor = [""] * len(vars) - state.uselogscale = [False] * len(vars) - state.invert = [False] * len(vars) - state.varmin = [np.nan] * len(vars) - state.varmax = [np.nan] * len(vars) - state.override_range = [False] * len(vars) - state.colorbar_images = [""] * len(vars) # Initialize empty images - state.varaverage = [np.nan] * len(vars) - else: - # Preserve loaded values but ensure arrays match variable count - # Extend or trim arrays to match new variable count if needed - current_len = ( - len(state.varcolor) - if hasattr(state, "varcolor") and state.varcolor - else 0 - ) - if current_len != len(vars): - # If array lengths don't match, extend with empty strings or trim - # ViewContext will populate correct values through sync - state.varcolor = (state.varcolor + [""] * len(vars))[: len(vars)] - state.uselogscale = (state.uselogscale + [False] * len(vars))[ - : len(vars) - ] - state.invert = (state.invert + [False] * len(vars))[: len(vars)] - state.varmin = (state.varmin + [np.nan] * len(vars))[: len(vars)] - state.varmax = (state.varmax + [np.nan] * len(vars))[: len(vars)] - state.override_range = (state.override_range + [False] * len(vars))[ - : len(vars) - ] - state.varaverage = (state.varaverage + [np.nan] * len(vars))[ - : len(vars) - ] - # Always reset colorbar images as they need to be regenerated - state.colorbar_images = [""] * len(vars) - - # Only use cached layout when explicitly requested (i.e., when loading state) - layout_to_use = self._cached_layout if use_cached_layout else None - self.viewmanager.rebuild_visualization_layout(layout_to_use) - # Update cached layout after rebuild - if state.layout and state.variables: - self._cached_layout = {} - for item in state.layout: - if isinstance(item, dict) and "i" in item: - idx = item["i"] - if idx < len(state.variables): - var_name = state.variables[idx] - self._cached_layout[var_name] = { - "x": item.get("x", 0), - "y": item.get("y", 0), - "w": item.get("w", 4), - "h": item.get("h", 3), - } - - def update_available_color_maps(self): - with self.state as state: - # Directly use the toggle states to determine which colormaps to show - if state.use_cvd_colors and state.use_standard_colors: - state.colormaps = cvd + noncvd - elif state.use_cvd_colors: - state.colormaps = cvd - elif state.use_standard_colors: - state.colormaps = noncvd - else: - # Fallback to standard colors if nothing is selected - state.colormaps = noncvd - state.colormaps.sort(key=lambda x: x["text"]) - - def zoom(self, type): - if type.lower() == "in": - self.viewmanager.zoom_in() - elif type.lower() == "out": - self.viewmanager.zoom_out() - - def pan_camera(self, dir): - if dir.lower() == "up": - self.viewmanager.pan_camera(1, 0) - elif dir.lower() == "down": - self.viewmanager.pan_camera(1, 1) - elif dir.lower() == "left": - self.viewmanager.pan_camera(0, 1) - elif dir.lower() == "right": - self.viewmanager.pan_camera(0, 0) - - def update_surface_var_selection(self, index, event): - with self.state as state: - state.surface_vars_state[index] = event - if self.ind_surface is not None: - ind = self.ind_surface[index] - self.surface_vars_state[ind] = event - else: - self.surface_vars_state[index] = event - self.state.dirty("surface_vars_state") - - def update_midpoint_var_selection(self, index, event): - with self.state as state: - state.midpoint_vars_state[index] = event - if self.ind_midpoint is not None: - ind = self.ind_midpoint[index] - self.midpoint_vars_state[ind] = event - else: - self.midpoint_vars_state[index] = event - self.state.dirty("midpoint_vars_state") - - def update_interface_var_selection(self, index, event): - with self.state as state: - state.interface_vars_state[index] = event - if self.ind_interface is not None: - ind = self.ind_interface[index] - self.interface_vars_state[ind] = event - else: - self.interface_vars_state[index] = event - self.state.dirty("interface_vars_state") - - def search_surface_vars(self, search: str): - if search is None or len(search) == 0: - filtVars = self.source.surface_vars - self.ind_surface = None - self.state.surface_vars = self.source.surface_vars - self.state.surface_vars_state = self.surface_vars_state.tolist() - self.state.dirty("surface_vars_state") - else: - filtered = [ - (idx, var) - for idx, var in enumerate(self.source.surface_vars) - if search.lower() in var.lower() - ] - filtVars = [var for (_, var) in filtered] - self.ind_surface = [idx for (idx, _) in filtered] - if self.ind_surface is not None: - self.state.surface_vars = list(filtVars) - self.state.surface_vars_state = self.surface_vars_state[ - self.ind_surface - ].tolist() - self.state.dirty("surface_vars_state") - - def search_midpoint_vars(self, search: str): - if search is None or len(search) == 0: - filtVars = self.source.midpoint_vars - self.ind_midpoint = None - self.state.midpoint_vars = self.source.midpoint_vars - self.state.midpoint_vars_state = self.midpoint_vars_state.tolist() - self.state.dirty("midpoint_vars_state") - else: - filtered = [ - (idx, var) - for idx, var in enumerate(self.source.midpoint_vars) - if search.lower() in var.lower() - ] - filtVars = [var for (_, var) in filtered] - self.ind_midpoint = [idx for (idx, _) in filtered] - if self.ind_midpoint is not None: - self.state.midpoint_vars = list(filtVars) - self.state.midpoint_vars_state = self.midpoint_vars_state[ - self.ind_midpoint - ].tolist() - self.state.dirty("midpoint_vars_state") - - def search_interface_vars(self, search: str): - if search is None or len(search) == 0: - filtVars = self.source.interface_vars - self.ind_interface = None - self.state.interface_vars = self.source.interface_vars - self.state.interface_vars_state = self.interface_vars_state.tolist() - self.state.dirty("interface_vars_state") - else: - filtered = [ - (idx, var) - for idx, var in enumerate(self.source.interface_vars) - if search.lower() in var.lower() - ] - filtVars = [var for (_, var) in filtered] - self.ind_interface = [idx for (idx, _) in filtered] - if self.ind_interface is not None: - self.state.interface_vars = list(filtVars) - self.state.interface_vars_state = self.interface_vars_state[ - self.ind_interface - ].tolist() - self.state.dirty("interface_vars_state") - - def clear_surface_vars(self, clear_var_name): - self.state[clear_var_name] = "" - self.ind_surface = None - self.state.surface_vars = self.source.surface_vars - self.state.surface_vars_state = [False] * len(self.source.surface_vars) - self.surface_vars_state = np.array([False] * len(self.source.surface_vars)) - self.state.dirty("surface_vars_state") - - def clear_midpoint_vars(self, clear_var_name): - self.state[clear_var_name] = "" - self.ind_midpoint = None - self.state.midpoint_vars = self.source.midpoint_vars - self.state.midpoint_vars_state = [False] * len(self.source.midpoint_vars) - self.midpoint_vars_state = np.array([False] * len(self.source.midpoint_vars)) - self.state.dirty("midpoint_vars_state") - - def clear_interface_vars(self, clear_var_name): - self.state[clear_var_name] = "" - self.ind_interface = None - self.state.interface_vars = self.source.interface_vars - self.state.interface_vars_state = [False] * len(self.source.interface_vars) - 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) - - @property - def ui(self) -> SinglePageWithDrawerLayout: - if self._ui is None: - self._ui = SinglePageWithDrawerLayout(self.server) - 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;", - ): - ( - html.Img( - src=f"data:image/png;base64,{LOGO_BASE64}", - style="height: 30px; width: 60px; border-radius: 4px; margin-bottom: 2px;", - ), - ) - html.Span( - f"v{version}", - style="font-size: 12px; color: rgba(0, 0, 0, 0.8); font-weight: 700; letter-spacing: 0.3px; line-height: 1;", - ) - """ - with layout.toolbar as toolbar: - Toolbar( - toolbar, - self.server, - load_data=self.load_data, - load_state=self.load_state, - load_variables=self.load_variables, - update_available_color_maps=self.update_available_color_maps, - generate_state=self.generate_state, - zoom=self.zoom, - move=self.pan_camera, - ) - - with layout.drawer as drawer: - drawer.width = 400 - drawer.style = ( - "background: none; border: none; pointer-events: none;" - ) - drawer.tile = True - - with v2.VCard( - classes="ma-2", - # elevation=5, - style="pointer-events: auto;", - flat=True, - ): - SliceSelection(self.source, self.viewmanager) - - ProjectionSelection(self.source, self.viewmanager) - - VariableSelection( - title="Surface Variables", - panel_name="show_surface_vars", - var_list="surface_vars", - var_list_state="surface_vars_state", - on_search=self.search_surface_vars, - on_clear=self.clear_surface_vars, - on_update=self.update_surface_var_selection, - ) - - VariableSelection( - title="Variables at Layer Midpoints", - panel_name="show_midpoint_vars", - var_list="midpoint_vars", - var_list_state="midpoint_vars_state", - on_search=self.search_midpoint_vars, - on_clear=self.clear_midpoint_vars, - on_update=self.update_midpoint_var_selection, - ) - - VariableSelection( - title="Variables at Layer Interfaces", - panel_name="show_interface_vars", - var_list="interface_vars", - var_list_state="interface_vars_state", - on_search=self.search_interface_vars, - on_clear=self.clear_interface_vars, - on_update=self.update_interface_var_selection, - ) - - with layout.content: - Grid( - self.server, - self.viewmanager, - self.close_view, - ) - return self._ui diff --git a/src/e3sm_quickview/ui/collapsible.py b/src/e3sm_quickview/ui/collapsible.py deleted file mode 100644 index f179df8..0000000 --- a/src/e3sm_quickview/ui/collapsible.py +++ /dev/null @@ -1,27 +0,0 @@ -from trame.widgets import vuetify2 as v2 -from trame_client.widgets.core import AbstractElement - - -class CollapsableSection(AbstractElement): - id_count = 0 - - def __init__(self, title, var_name=None, expended=False): - super().__init__(None) - CollapsableSection.id_count += 1 - show = var_name or f"show_section_{CollapsableSection.id_count}" - with v2.VCardSubtitle( - classes="pa-0 d-flex align-center font-weight-bold pointer", - click=f"{show} = !{show}", - ) as container: - v2.VIcon( - f"{{{{ {show} ? 'mdi-menu-down' : 'mdi-menu-right' }}}}", - size="sm", - classes="pa-0 ma-0", - ) - container.add_child(title) - self.content = v2.VSheet( - classes="overflow-hidden mx-2 mb-3", - rounded="lg", - style="border: 2px solid #ccc;", - v_show=(show, expended), - ) diff --git a/src/e3sm_quickview/ui/grid.py b/src/e3sm_quickview/ui/grid.py deleted file mode 100644 index 53f9fb2..0000000 --- a/src/e3sm_quickview/ui/grid.py +++ /dev/null @@ -1,447 +0,0 @@ -from trame.widgets import grid -from trame.widgets import vuetify as v2, html, client -from e3sm_quickview.ui.view_settings import ViewProperties -from datetime import datetime -from paraview.simple import SaveScreenshot -from paraview.simple import GetColorTransferFunction -from e3sm_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/src/e3sm_quickview/ui/projection_selection.py b/src/e3sm_quickview/ui/projection_selection.py deleted file mode 100644 index 0eefce4..0000000 --- a/src/e3sm_quickview/ui/projection_selection.py +++ /dev/null @@ -1,53 +0,0 @@ -from trame.decorators import TrameApp, change -from trame.widgets import html, vuetify2 as v2 - -from e3sm_quickview.ui.collapsible import CollapsableSection - -from e3sm_quickview.view_manager import ViewManager -from e3sm_quickview.pipeline import EAMVisSource - - -@TrameApp() -class ProjectionSelection(CollapsableSection): - def __init__(self, source: EAMVisSource, view_manager: ViewManager): - super().__init__("Map Projection", "show_projection") - - self.source = source - self.views = view_manager - - self.state.center = 0.0 - - with self.content: - with v2.VCard(flat=True, elevation=0, classes="pa-2"): - with v2.VRow(classes="align-center", no_gutters=True): - with v2.VCol(cols=4, classes="text-right pr-3"): - html.Div( - "Projection:", - classes="text-body-2 font-weight-medium", - style="color: #616161;", - ) - with v2.VCol(cols=8): - v2.VSelect( - items=( - "options", - ["Cyl. Equidistant", "Robinson", "Mollweide"], - ), - v_model=("projection", "Cyl. Equidistant"), - outlined=True, - dense=True, - hide_details=True, - color="primary", - classes="elevation-0", - ) - - @change("projection") - def update_pipeline_interactive(self, **kwargs): - projection = self.state.projection - self.source.UpdateProjection(projection) - tstamp = self.state.tstamp - time = 0.0 if len(self.state.timesteps) == 0 else self.state.timesteps[tstamp] - self.source.UpdatePipeline(time) - # For projection changes, we need to fit viewports to new bounds - self.views.update_views_for_timestep() - # Render once after all updates - self.views.render_all_views() diff --git a/src/e3sm_quickview/ui/slice_selection.py b/src/e3sm_quickview/ui/slice_selection.py deleted file mode 100644 index b51f88d..0000000 --- a/src/e3sm_quickview/ui/slice_selection.py +++ /dev/null @@ -1,289 +0,0 @@ -from trame.app import asynchronous -from trame.decorators import TrameApp, change -from trame.widgets import html, vuetify2 as v2 - -from e3sm_quickview.ui.collapsible import CollapsableSection - -from e3sm_quickview.view_manager import ViewManager -from e3sm_quickview.pipeline import EAMVisSource - -import asyncio - - -@TrameApp() -class SliceSelection(CollapsableSection): - def __init__(self, source: EAMVisSource, view_manager: ViewManager): - super().__init__("Slice Selection", "show_slice") - - self.source = source - self.views = view_manager - - style = dict(dense=True, hide_details=True) - with self.content: - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pt-3 px-3" - ): - with v2.VCol(classes="text-left py-0"): - html.Div("Layer Midpoints", classes="mb-1") - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_middle, "[-1]"), - ): - v2.VIcon("mdi-skip-previous", small=True) - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_middle, "[1]"), - ): - v2.VIcon("mdi-skip-next", small=True) - with v2.VCol(classes="mr-4 py-0", cols=1): - v2.VCheckbox( - v_model=("play_lev", False), - off_icon="mdi-play-circle", - on_icon="mdi-stop-circle", - classes="ma-0 pa-0", - **style, - ) - - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pb-2 px-3" - ): - with v2.VCol(cols=8, classes="py-0 pl-3"): - v2.VSlider( - v_model=("midpoint", 0), - min=0, - max=("Math.max(0, midpoints.length - 1)",), - color="primary", - classes="py-0 pl-3", - **style, - ) - with v2.VCol(cols=4, classes="text-left py-0"): - html.Div( - "{{midpoints.length > 0 ? parseFloat(midpoints[midpoint]).toFixed(2) + ' hPa (k=' + midpoint + ')' : '0.00 hPa (k=0)'}}", - classes="font-weight-medium", - ) - # v2.VDivider(classes="my-2") - - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pt-3 px-3" - ): - with v2.VCol(classes="text-left py-0"): - html.Div("Layer Interfaces", classes="mb-1") - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_interface, "[-1]"), - ): - v2.VIcon("mdi-skip-previous", small=True) - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_interface, "[1]"), - ): - v2.VIcon("mdi-skip-next", small=True) - with v2.VCol(classes="mr-4 py-0", cols=1): - v2.VCheckbox( - v_model=("play_ilev", False), - off_icon="mdi-play-circle", - on_icon="mdi-stop-circle", - classes="ma-0 pa-0", - **style, - ) - - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pb-2 px-3" - ): - with v2.VCol(cols=8, classes="py-0"): - v2.VSlider( - v_model=("interface", 0), - min=0, - max=("Math.max(0, interfaces.length - 1)",), - color="secondary", - classes="py-0 pl-3", - **style, - ) - with v2.VCol(cols=4, classes="text-left py-0"): - html.Div( - "{{interfaces.length > 0 ? parseFloat(interfaces[interface]).toFixed(2) + ' hPa (k=' + interface + ')' : '0.00 hPa (k=0)'}}", - classes="font-weight-medium", - ) - # v2.VDivider(classes="my-2") - - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pt-3 px-3" - ): - with v2.VCol(classes="text-left py-0"): - html.Div("Time", classes="mb-1") - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_time, "[-1]"), - ): - v2.VIcon("mdi-skip-previous", small=True) - with v2.VCol(classes="py-0", cols=1): - with v2.VBtn( - icon=True, - flat=True, - **style, - click=(self.on_click_advance_time, "[1]"), - ): - v2.VIcon("mdi-skip-next", small=True) - with v2.VCol(classes="mr-4 py-0", cols=1): - v2.VCheckbox( - v_model=("play_time", False), - off_icon="mdi-play-circle", - on_icon="mdi-stop-circle", - classes="ma-0 pa-0", - **style, - ) - - with v2.VRow( - classes="text-center align-center justify-center text-subtitle-1 pb-2 px-3" - ): - with v2.VCol(cols=8, classes="py-0"): - v2.VSlider( - v_model=("tstamp", 0), - min=0, - max=("Math.max(0, timesteps.length - 1)",), - color="accent", - classes="py-0 pl-3", - **style, - ) - with v2.VCol(cols=4, classes="text-left py-0"): - html.Div( - "{{timesteps.length > 0 ? parseFloat(timesteps[tstamp]).toFixed(2) + ' (t=' + tstamp + ')' : '0.00 (t=0)'}}", - classes="font-weight-medium", - ) - # v2.VDivider(classes="my-4") - - with v2.VRow(classes="text-center align-center text-subtitle-1 pt-2 pa-2"): - with v2.VCol(cols=3, classes="py-0"): - html.Div( - "{{ cliplong[0].toFixed(1) }}°", - classes="font-weight-medium text-center", - ) - with v2.VCol(cols=6, classes="py-0"): - html.Div("Longitude") - with v2.VCol(cols=3, classes="py-0"): - html.Div( - "{{ cliplong[1].toFixed(1) }}°", - classes="font-weight-medium text-center", - ) - v2.VRangeSlider( - v_model=("cliplong", [self.source.extents[0], self.source.extents[1]]), - min=-180, - max=180, - step=0.5, - color="blue-grey", - **style, - flat=True, - variant="solo", - classes="pt-2 px-6", - ) - # v2.VDivider(classes="my-4") - - with v2.VRow(classes="text-center align-center text-subtitle-1 pt-4 px-2"): - with v2.VCol(cols=3, classes="py-0"): - html.Div( - "{{ cliplat[0].toFixed(1) }}°", - classes="font-weight-medium text-center", - ) - with v2.VCol(cols=6, classes="py-0"): - html.Div("Latitude") - with v2.VCol(cols=3, classes="py-0"): - html.Div( - "{{ cliplat[1].toFixed(1) }}°", - classes="font-weight-medium text-center", - ) - v2.VRangeSlider( - v_model=("cliplat", [self.source.extents[2], self.source.extents[3]]), - min=-90, - max=90, - step=0.5, - color="blue-grey", - **style, - flat=True, - variant="solo", - classes="pt-2 px-6", - ) - - @change("midpoint", "interface", "tstamp", "cliplat", "cliplong") - def update_pipeline_interactive(self, **kwargs): - lev = self.state.midpoint - ilev = self.state.interface - tstamp = self.state.tstamp - long = self.state.cliplong - lat = self.state.cliplat - time = 0.0 if len(self.state.timesteps) == 0 else self.state.timesteps[tstamp] - - self.source.UpdateLev(lev, ilev) - self.source.UpdateTimeStep(tstamp) - self.source.ApplyClipping(long, lat) - self.source.UpdatePipeline(time) - - # update_views_for_timestep will handle fitting and rendering - self.views.update_views_for_timestep() - # Render once after all updates - self.views.render_all_views() - - def on_click_advance_middle(self, diff): - if len(self.state.midpoints) > 0: - current = self.state.midpoint - update = current + diff - self.state.midpoint = update % len(self.state.midpoints) - - @change("play_lev") - @asynchronous.task - async def play_lev(self, **kwargs): - state = self.state - while state.play_lev: - state.play_ilev = False - state.play_time = False - with state: - self.on_click_advance_middle(1) - await asyncio.sleep(0.1) - - def on_click_advance_interface(self, diff): - if len(self.state.interfaces) > 0: - current = self.state.interface - update = current + diff - self.state.interface = update % len(self.state.interfaces) - - @change("play_ilev") - @asynchronous.task - async def play_ilev(self, **kwargs): - state = self.state - while state.play_ilev: - state.play_lev = False - state.play_time = False - with state: - self.on_click_advance_interface(1) - await asyncio.sleep(0.1) - - def on_click_advance_time(self, diff): - if len(self.state.timesteps) > 0: - current = self.state.tstamp - update = current + diff - self.state.tstamp = update % len(self.state.timesteps) - - @change("play_time") - @asynchronous.task - async def play_time(self, **kwargs): - state = self.state - while state.play_time: - state.play_lev = False - state.play_ilev = False - with state: - self.on_click_advance_time(1) - await asyncio.sleep(0.1) diff --git a/src/e3sm_quickview/ui/toolbar.py b/src/e3sm_quickview/ui/toolbar.py deleted file mode 100644 index 5282527..0000000 --- a/src/e3sm_quickview/ui/toolbar.py +++ /dev/null @@ -1,305 +0,0 @@ -from trame.decorators import TrameApp, task -from trame.widgets import html, vuetify2 as v2 -from e3sm_quickview.ui.view_settings import ViewControls - -import json - - -@TrameApp() -class Toolbar: - @task - async def select_data_file(self): - 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 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): - # Small delay to ensure client state is synchronized - import asyncio - - await asyncio.sleep(0.1) - - if self._generate_state is not None: - config = self._generate_state() - 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 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): - return self.server.state - - @property - def ctrl(self): - return self.server.controller - - def _handle_cvd_toggle(self): - """Handle CVD-friendly colors toggle button click""" - with self.state: - # Toggle CVD colors, but ensure at least one option is selected - if not self.state.use_cvd_colors or self.state.use_standard_colors: - self.state.use_cvd_colors = not self.state.use_cvd_colors - self._update_color_maps() - - def _handle_standard_toggle(self): - """Handle standard colors toggle button click""" - with self.state: - # Toggle standard colors, but ensure at least one option is selected - if not self.state.use_standard_colors or self.state.use_cvd_colors: - self.state.use_standard_colors = not self.state.use_standard_colors - self._update_color_maps() - - def _update_color_maps(self): - """Update the available color maps based on toggle states""" - if self._update_available_color_maps is not None: - # Directly call update_available_color_maps without parameters - self._update_available_color_maps() - - def __init__( - self, - layout_toolbar, - server, - load_data=None, - load_state=None, - load_variables=None, - update_available_color_maps=None, - generate_state=None, - zoom=None, - move=None, - **kwargs, - ): - self.server = server - - self._generate_state = generate_state - self._load_state = load_state - self._update_available_color_maps = update_available_color_maps - - # Set initial color maps based on default toggle states - self._update_color_maps() - - with layout_toolbar as toolbar: - toolbar.density = "compact" - toolbar.style = "overflow-x: auto; overflow-y: hidden;" - with html.Div( - style="min-width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;" - ): - v2.VProgressCircular( - bg_color="rgba(0,0,0,0)", - indeterminate=("trame__busy",), - color="primary", - size=24, - ) - v2.VDivider(vertical=True, classes="mx-2") - v2.VBtn( - "Load Variables", - classes="ma-2", - color="primary", - dense=True, - # flat=True, - tonal=True, - small=True, - click=load_variables, - style="background-color: lightgray;", # width: 200px; height: 50px;", - ) - v2.VSpacer() - v2.VDivider(vertical=True, classes="mx-2") - # Color options toggle buttons group - with v2.VCard( - flat=True, - classes="d-flex align-center px-2 py-1 mx-1", - style="background-color: #f5f5f5; border-radius: 4px; flex-shrink: 0;", - ): - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - small=True, - v_bind="attrs", - v_on="on", - click=self._handle_cvd_toggle, - color=("use_cvd_colors ? 'primary' : ''",), - classes="mx-1", - ): - v2.VIcon("mdi-eye-check-outline") - html.Span("CVD-friendly colors") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - small=True, - v_bind="attrs", - v_on="on", - click=self._handle_standard_toggle, - color=("use_standard_colors ? 'primary' : ''",), - classes="mx-1", - ): - v2.VIcon("mdi-palette") - html.Span("Standard colors") - v2.VDivider(vertical=True, classes="mx-2") - with v2.VCard( - flat=True, - classes="d-flex align-center px-2 py-1 mx-1", - style="background-color: #f5f5f5; border-radius: 4px; min-width: 35%; flex-shrink: 1;", - ): - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - v2.VTextField( - prepend_icon="mdi-vector-rectangle", - placeholder="Connectivity", - v_model=("conn_file", ""), - hide_details=True, - dense=True, - append_icon="mdi-folder-upload", - click_append=self.select_connectivity_file, - filled=True, - background_color="white", - classes="mr-2", - style="max-width: 48%;", - v_bind="attrs", - v_on="on", - ) - html.Span("Connectivity file (SCRIP format .nc file)") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - v2.VTextField( - prepend_icon="mdi-database", - placeholder="Data File", - v_model=("data_file", ""), - hide_details=True, - dense=True, - append_icon="mdi-folder-upload", - click_append=self.select_data_file, - filled=True, - background_color="white", - style="max-width: 48%;", - v_bind="attrs", - v_on="on", - ) - html.Span("EAM simulation output (.nc file)") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - flat=True, - small=True, - click=load_data, - color=("!pipeline_valid ? 'primary' : 'secondary'",), - v_bind="attrs", - v_on="on", - ): - v2.VIcon("mdi-file-check") - html.Span("Load Files") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - flat=True, - small=True, - click=self.export_state, - v_bind="attrs", - v_on="on", - classes="mx-1", - ): - v2.VIcon("mdi-download") - html.Span("Save State") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - flat=True, - small=True, - click=self.import_state, - v_bind="attrs", - v_on="on", - classes="mx-1", - ): - v2.VIcon("mdi-upload") - html.Span("Load State") - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - flat=True, - small=True, - v_bind="attrs", - v_on="on", - ): - v2.VIcon( - v_if="pipeline_valid", - children=["mdi-check-circle-outline"], - color="green", - ) - v2.VIcon( - v_if="!pipeline_valid", - children=["mdi-alert-circle-outline"], - color="red", - ) - with html.Div(v_if="pipeline_valid"): - html.Span("Pipeline Valid") - with html.Div(v_if="!pipeline_valid"): - html.Span("Pipeline Invalid") - v2.VDivider(vertical=True, classes="mx-2") - ViewControls( - zoom=zoom, - move=move, - style="flex-shrink: 0;", - ) - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - v_bind="attrs", - v_on="on", - click=self.ctrl.view_reset_camera, - ): - v2.VIcon("mdi-restore") - html.Span("Reset View Cameras") diff --git a/src/e3sm_quickview/ui/variable_selection.py b/src/e3sm_quickview/ui/variable_selection.py deleted file mode 100644 index 3188470..0000000 --- a/src/e3sm_quickview/ui/variable_selection.py +++ /dev/null @@ -1,105 +0,0 @@ -from trame.decorators import TrameApp -from trame.widgets import html, vuetify2 as v2 - -from e3sm_quickview.ui.collapsible import CollapsableSection - -style = dict(dense=True, hide_details=True) - - -class SelectionList(v2.VContainer): - def __init__(self, variables, state, update=None): - super().__init__( - fluid=True, - style="max-height: 180px; background: #f5f5f5; border-radius: 4px;", - classes="overflow-y-auto pa-1 mt-1", - ) - with self: - with v2.VListItemGroup(**style): - with v2.VHover(v_for=f"v, i in {variables}", key="i"): - with html.Template(v_slot_default="{ hover }"): - with v2.VCard( - flat=True, - color=("hover ? 'grey lighten-4' : 'transparent'",), - classes="px-1 mb-0", - style="transition: all 0.2s;", - ): - v2.VCheckbox( - label=(f"{variables}[i]",), - v_model=(f"{state}[i]",), - change=(update, "[i, $event]"), - color="primary", - classes="ma-0 pa-0", - style="height: 24px;", - dense=True, - hide_details=True, - ) - - -@TrameApp() -class VariableSelection(CollapsableSection): - _next_id = 0 - - @classmethod - def next_id(cls): - """Get the next unique ID for the scalar bar.""" - cls._next_id += 1 - return f"var_select_{cls._next_id}" - - def __init__( - self, - title=None, - panel_name=None, - var_list=None, - var_list_state=None, - on_search=None, - on_clear=None, - on_update=None, - ): - super().__init__(title=title, var_name=panel_name) - - ns = self.next_id() - self.__search_var = f"{ns}_search" - - with self.content: - # Search and controls section - with v2.VCard(flat=True, elevation=0, classes="pa-2 mb-1"): - with v2.VRow(classes="align-center", no_gutters=True): - with v2.VCol(cols=9, classes="pr-1"): - v2.VTextField( - v_model=(self.__search_var, ""), - prepend_inner_icon="mdi-magnify", - label="Search variables", - placeholder="Type to filter...", - change=(on_search, "[$event]"), - clearable=True, - outlined=True, - dense=True, - hide_details=True, - classes="elevation-0", - ) - with v2.VCol(cols=3): - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - click=(on_clear, f"['{self.__search_var}']"), - depressed=True, - small=True, - v_bind="attrs", - v_on="on", - classes="elevation-1", - style="width: 100%;", - ): - v2.VIcon( - "mdi-close-box-multiple", left=True, small=True - ) - html.Span("Clear", classes="text-caption") - html.Span("Clear all selections") - - # Variables list - with v2.VCard(flat=True, elevation=0, classes="px-2"): - html.Div( - "Variables", - classes="text-caption font-weight-medium mb-0", - style="color: #616161;", - ) - SelectionList(var_list, var_list_state, on_update) diff --git a/src/e3sm_quickview/ui/view_settings.py b/src/e3sm_quickview/ui/view_settings.py deleted file mode 100644 index 276a63c..0000000 --- a/src/e3sm_quickview/ui/view_settings.py +++ /dev/null @@ -1,213 +0,0 @@ -from trame.widgets import vuetify2 as v2, html -from trame.decorators import TrameApp - - -@TrameApp() -class ViewProperties(v2.VMenu): - def __init__( - self, - update_colormap=None, - update_log_scale=None, - update_invert=None, - update_range=None, - reset=None, - **kwargs, - ): - super().__init__( - transition="slide-y-transition", - close_on_content_click=False, - persistent=True, - no_click_animation=True, - offset_y=True, - **kwargs, - ) - with self: - with v2.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - dense=True, - small=True, - outlined=True, - classes="pa-0", - style="background: white; width: 24px; height: 24px;", - v_bind="attrs", - v_on="on", - ): - v2.VIcon("mdi-cog", small=True) - style = dict(dense=True, hide_details=True) - with v2.VCard( - classes="overflow-hidden pa-2", - rounded="lg", - ): - with v2.VCardText(classes="pa-2"): - v2.VSelect( - label="Color Map", - v_model=("varcolor[idx]",), - items=("colormaps",), - outlined=True, - change=( - update_colormap, - "[idx, $event]", - ), - **style, - ) - html.Div("Color Map Options", classes="pt-2") - with v2.VRow(): - with v2.VCol(): - v2.VCheckbox( - label="Log Scale", - v_model=("uselogscale[idx]",), - change=( - update_log_scale, - "[idx, $event]", - ), - **style, - ) - with v2.VCol(): - v2.VCheckbox( - label="Revert Colors", - v_model=("invert[idx]",), - change=( - update_invert, - "[idx, $event]", - ), - **style, - ) - with html.Div(classes="pt-2 d-flex align-center"): - html.Span("Value Range", classes="mr-2") - with v2.VChip( - v_if=("override_range[idx]",), - x_small=True, - color="primary", - dark=True, - classes="ml-auto", - ): - v2.VIcon("mdi-lock", x_small=True, left=True) - html.Span("Manual") - with v2.VRow(): - with v2.VCol(): - v2.VTextField( - v_model=("varmin[idx]",), - label="min", - outlined=True, - change=( - update_range, - "[idx, 'min', $event]", - ), - style="height=50px", - color=("override_range[idx] ? 'primary' : ''",), - **style, - ) - with v2.VCol(): - v2.VTextField( - v_model=("varmax[idx]",), - label="max", - outlined=True, - change=( - update_range, - "[idx, 'max', $event]", - ), - style="height=50px", - color=("override_range[idx] ? 'primary' : ''",), - **style, - ) - with html.Div(classes="pt-2 align-center text-center"): - v2.VBtn( - "Reset Colors to Data Range", - outlined=True, - style="background-color: gray; color: white;", - click=( - reset, - "[idx]", - ), - ) - - -@TrameApp() -class ViewControls(v2.VCard): - def __init__(self, zoom=None, move=None, **kwargs): - # Merge any incoming style with our default style - default_style = "background-color: #f5f5f5; border-radius: 4px;" - incoming_style = kwargs.pop("style", "") - merged_style = f"{default_style} {incoming_style}".strip() - - super().__init__( - flat=True, - classes="d-flex align-center px-2 py-1 mx-1", - style=merged_style, - **kwargs, - ) - with self: - """ - with v2.Template(v_slot_activator="{ on, attrs }"): - with v2.VBtn( - icon=True, - outlined=True, - classes="pa-1", - style="background: white;", - v_bind="attrs", - v_on="on", - ): - v2.VIcon("mdi-camera") - style = dict(dense=True, hide_details=True) - """ - btn_style = dict( - icon=True, - flat=True, - outlined=False, - density="compact", - hide_details=True, - height="28px", - width="28px", - classes="ma-0", - ) - - with v2.VCardText(classes="pa-1", style="opacity: 85%"): - with v2.VTooltip(bottom=True): - with html.Template(v_slot_activator="{ on, attrs }"): - with html.Div( - v_bind="attrs", - v_on="on", - classes="d-flex flex-column", - style="gap: 2px;", - ): - # First row: Up, Left, Zoom In - with html.Div( - classes="d-flex justify-center", style="gap: 2px;" - ): - with v2.VBtn( - **btn_style, - click=(move, "['up']"), - ): - v2.VIcon("mdi-arrow-up-thick", size="18") - with v2.VBtn( - **btn_style, - click=(move, "['left']"), - ): - v2.VIcon("mdi-arrow-left-thick", size="18") - with v2.VBtn( - **btn_style, - click=(zoom, "['in']"), - ): - v2.VIcon("mdi-magnify-plus", size="18") - - # Second row: Down, Right, Zoom Out - with html.Div( - classes="d-flex justify-center", style="gap: 2px;" - ): - with v2.VBtn( - **btn_style, - click=(move, "['down']"), - ): - v2.VIcon("mdi-arrow-down-thick", size="18") - with v2.VBtn( - **btn_style, - click=(move, "['right']"), - ): - v2.VIcon("mdi-arrow-right-thick", size="18") - with v2.VBtn( - **btn_style, - click=(zoom, "['out']"), - ): - v2.VIcon("mdi-magnify-minus", size="18") - html.Span("View Camera Controls", classes="text-caption mt-1") diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index ad044b0..7e26461 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -1,774 +1,521 @@ -import paraview.servermanager as sm - -from trame.widgets import paraview as pvWidgets -from trame.decorators import TrameApp, trigger, change - -from paraview.simple import ( - Delete, - Show, - CreateRenderView, - ColorBy, - GetColorTransferFunction, - AddCameraLink, - Render, -) - -from e3sm_quickview.pipeline import EAMVisSource -from typing import Dict, List, Optional - -from e3sm_quickview.utils.math import calculate_weighted_average -from e3sm_quickview.utils.color import get_cached_colorbar_image -from e3sm_quickview.utils.geometry import ( - generate_annotations as generate_map_annotations, -) - -# Constants for camera and display -LABEL_OFFSET_FACTOR = 0.075 # Factor for offsetting labels from map edge -ZOOM_IN_FACTOR = 0.95 # Scale factor for zooming in -ZOOM_OUT_FACTOR = 1.05 # Scale factor for zooming out -DEFAULT_MARGIN = 1.05 # Default margin for viewport fitting (5% margin) -GRATICULE_INTERVAL = 30 # Default interval for map graticule in degrees -PAN_OFFSET_RATIO = 0.05 # Ratio of extent to use for pan offset (5%) - -# Grid layout constants -DEFAULT_GRID_COLUMNS = 3 # Number of columns in default grid layout -DEFAULT_GRID_WIDTH = 4 # Default width of grid items -DEFAULT_GRID_HEIGHT = 3 # Default height of grid items - - -class ViewRegistry: - """Central registry for managing views""" - - def __init__(self): - self._contexts: Dict[str, "ViewContext"] = {} - self._view_order: List[str] = [] - - def register_view(self, variable: str, context: "ViewContext"): - """Register a new view or update existing one""" - self._contexts[variable] = context - if variable not in self._view_order: - self._view_order.append(variable) - - def get_view(self, variable: str) -> Optional["ViewContext"]: - """Get view context for a variable""" - return self._contexts.get(variable) - - def remove_view(self, variable: str): - """Remove a view from the registry""" - if variable in self._contexts: - del self._contexts[variable] - self._view_order.remove(variable) - - def get_ordered_views(self) -> List["ViewContext"]: - """Get all views in order they were added""" - return [ - self._contexts[var] for var in self._view_order if var in self._contexts - ] - - def get_all_variables(self) -> List[str]: - """Get all registered variable names""" - return list(self._contexts.keys()) - - def items(self): - """Iterate over variable-context pairs""" - return self._contexts.items() - - def clear(self): - """Clear all registered views""" - self._contexts.clear() - self._view_order.clear() - - def __len__(self): - """Get number of registered views""" - return len(self._contexts) - - def __contains__(self, variable: str): - """Check if a variable is registered""" - return variable in self._contexts - - -class ViewConfiguration: - """Mutable configuration for a view - what the user can control""" - - def __init__( - self, - variable: str, - colormap: str, - use_log_scale: bool = False, - invert_colors: bool = False, - min_value: float = None, - max_value: float = None, - override_range: bool = False, - ): - self.variable = variable - self.colormap = colormap - self.use_log_scale = use_log_scale - self.invert_colors = invert_colors - self.min_value = min_value - self.max_value = max_value - self.override_range = override_range # True when user manually sets min/max - - -class ViewState: - """Runtime state for a view - ParaView objects""" - - def __init__( - self, - view_proxy=None, - data_representation=None, - ): - self.view_proxy = view_proxy - self.data_representation = data_representation - - -class ViewContext: - """Complete context for a rendered view combining configuration and state""" - - def __init__(self, config: ViewConfiguration, state: ViewState, index: int): - self.config = config - self.state = state - self.index = index - - -def apply_projection(projection, point): - if projection is None: - return point - else: - new = projection.transform(point[0] - 180, point[1]) - return [new[0], new[1], 1.0] - - -def generate_annotations(long, lat, projection, center): - return generate_map_annotations( - long, - lat, - projection, - center, - interval=GRATICULE_INTERVAL, - label_offset_factor=LABEL_OFFSET_FACTOR, - ) - - -def build_color_information(state: map): - vars = state["variables"] - colors = state["varcolor"] - logscl = state["uselogscale"] - invert = state["invert"] - varmin = state["varmin"] - varmax = state["varmax"] - # Get override_range from state if available - override_range = state.get("override_range", None) - # Store layout from state if available for backward compatibility - layout = state.get("layout", None) - - registry = ViewRegistry() - for index, var in enumerate(vars): - # Use provided override_range if available - if override_range is not None and index < len(override_range): - override = override_range[index] - else: - # Legacy behavior for older saved states without override_range - override = True - - config = ViewConfiguration( - variable=var, - colormap=colors[index], - use_log_scale=logscl[index], - invert_colors=invert[index], - min_value=varmin[index], - max_value=varmax[index], - override_range=override, - ) - view_state = ViewState() - context = ViewContext(config, view_state, index) - registry.register_view(var, context) - - # Store layout info in registry for later use - if layout: - registry._saved_layout = [item.copy() for item in layout] - - return registry - - -@TrameApp() -class ViewManager: - def __init__(self, source: EAMVisSource, server, state): - self.server = server +import math + +from trame.app import TrameComponent +from trame.ui.html import DivLayout +from trame.widgets import paraview as pvw, vuetify3 as v3, client, html +from trame.decorators import controller + +from trame_dataclass.core import StateDataModel + +from paraview import simple + +from e3sm_quickview.components import view +from e3sm_quickview.utils.color import get_cached_colorbar_image, COLORBAR_CACHE +from e3sm_quickview.presets import COLOR_BLIND_SAFE + + +def auto_size_to_col(size): + if size == 1: + return 12 + + if size >= 8 and size % 2 == 0: + return 3 + + if size % 3 == 0: + return 4 + + if size % 2 == 0: + return 6 + + return auto_size_to_col(size + 1) + + +COL_SIZE_LOOKUP = { + 0: auto_size_to_col, + 1: 12, + 2: 6, + 3: 4, + 4: 3, + 6: 2, + 12: 1, + "flow": None, +} + +TYPE_COLOR = { + "s": "success", + "i": "info", + "m": "warning", +} + + +def lut_name(element): + return element.get("name").lower() + + +class ViewConfiguration(StateDataModel): + variable: str + preset: str = "Inferno (matplotlib)" + preset_img: str + invert: bool = False + color_blind: bool = False + use_log_scale: bool = False + color_value_min: str = "0" + color_value_max: str = "1" + color_value_min_valid: bool = True + color_value_max_valid: bool = True + color_range: list[float] = (0, 1) + override_range: bool = False + order: int = 0 + size: int = 4 + offset: int = 0 + break_row: bool = False + menu: bool = False + swap_group: list[str] + search: str | None + + +class VariableView(TrameComponent): + def __init__(self, server, source, variable_name, variable_type): + super().__init__(server) + self.config = ViewConfiguration(server, variable=variable_name) self.source = source - self.state = state - self.widgets = [] - self.registry = ViewRegistry() # Central registry for view management - self.to_delete = [] - - def get_default_colormap(self): - """Get default colormap from interface or fallback""" - # Try to get from the server's interface instance - if self.state.use_cvd_colors: - return "batlow" - # Fallback to a reasonable default - return "Cool to Warm (Extended)" - - @change("pipeline_valid") - def _on_change_pipeline_valid(self, pipeline_valid, **kwargs): - """Clear view registry when pipeline becomes invalid.""" - if not pipeline_valid: - print("Clearing registry") - # Clear all views and variables from registry - self.registry.clear() - # Clear widgets and colors tracking - del self.state.views[:] - del self.state.layout[:] - self.state.dirty("views") - self.state.dirty("layout") - - 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) - state.varcolor.pop(index) - state.varmin.pop(index) - state.varmax.pop(index) - state.uselogscale.pop(index) - 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") - self.state.dirty("varmax") - self.state.dirty("uselogscale") - self.state.dirty("override_range") - self.state.dirty("invert") - self.state.dirty("colorbar_images") - self.rebuild_after_close(layout_cache) - - def update_views_for_timestep(self): - if len(self.registry) == 0: - return - data = sm.Fetch(self.source.views["atmosphere_data"]) - - first_view = None - for var, context in self.registry.items(): - index = self.state.variables.index(var) - varavg = self.compute_average(var, vtkdata=data) - # Directly set average in trame state - self.state.varaverage[index] = varavg - self.state.dirty("varaverage") - if not context.config.override_range: - context.state.data_representation.RescaleTransferFunctionToDataRange( - False, True - ) - range = self.compute_range(var=var) - context.config.min_value = range[0] - context.config.max_value = range[1] - self.sync_color_config_to_state(index, context) - self.generate_colorbar_image(index) - - # Track the first view for camera fitting - if first_view is None and context.state.view_proxy: - first_view = context.state.view_proxy - - if first_view is not None: - first_view.ResetCamera(True, 0.9) - - def refresh_view_display(self, context: ViewContext): - if not context.config.override_range: - context.state.data_representation.RescaleTransferFunctionToDataRange( - False, True - ) - rview = context.state.view_proxy - - Render(rview) - # ResetCamera(rview) - - def configure_new_view(self, var, context: ViewContext, sources): - rview = context.state.view_proxy - - # Update unique sources to all render views - data = sources["atmosphere_data"] - rep = Show(data, rview) - context.state.data_representation = rep - ColorBy(rep, ("CELLS", var)) - coltrfunc = GetColorTransferFunction(var) - coltrfunc.ApplyPreset(context.config.colormap, True) - coltrfunc.NanOpacity = 0.0 - - # Apply log scale if configured - if context.config.use_log_scale: - coltrfunc.MapControlPointsToLogSpace() - coltrfunc.UseLogScale = 1 - - # Apply inversion if configured - if context.config.invert_colors: - coltrfunc.InvertTransferFunction() - - # Ensure the color transfer function is scaled to the data range - if not context.config.override_range: - rep.RescaleTransferFunctionToDataRange(False, True) - else: - coltrfunc.RescaleTransferFunction( - context.config.min_value, context.config.max_value - ) + self.variable_name = variable_name + self.variable_type = variable_type + self.name = f"view_{self.variable_name}" + self.view = simple.CreateRenderView() + self.view.GetRenderWindow().SetOffScreenRendering(True) + self.view.InteractionMode = "2D" + self.view.OrientationAxesVisibility = 0 + self.view.UseColorPaletteForBackground = 0 + self.view.BackgroundColorMode = "Gradient" + self.view.CameraParallelProjection = 1 + self.view.Size = 0 # make the interactive widget non responsive + self.representation = simple.Show( + proxy=source.views["atmosphere_data"], + view=self.view, + ) - # ParaView scalar bar is always hidden - using custom HTML colorbar instead + # Lookup table color management + simple.ColorBy(self.representation, ("CELLS", variable_name)) + self.lut = simple.GetColorTransferFunction(variable_name) + self.lut.NanOpacity = 0.0 - # Update common sources to all render views + self.view.ResetActiveCameraToNegativeZ() + self.view.ResetCamera(True, 0.9) + self.disable_render = False - globe = sources["continents"] - repG = Show(globe, rview) - ColorBy(repG, None) + # Add annotation to the view + # - continents + globe = source.views["continents"] + repG = simple.Show(globe, self.view) + simple.ColorBy(repG, None) repG.SetRepresentationType("Wireframe") repG.RenderLinesAsTubes = 1 repG.LineWidth = 1.0 repG.AmbientColor = [0.67, 0.67, 0.67] repG.DiffuseColor = [0.67, 0.67, 0.67] - - annot = sources["grid_lines"] - repAn = Show(annot, rview) + self.rep_globe = repG + # - gridlines + annot = source.views["grid_lines"] + repAn = simple.Show(annot, self.view) repAn.SetRepresentationType("Wireframe") repAn.AmbientColor = [0.67, 0.67, 0.67] repAn.DiffuseColor = [0.67, 0.67, 0.67] repAn.Opacity = 0.4 + self.rep_grid = repAn - # Always hide ParaView scalar bar - using custom HTML colorbar - rep.SetScalarBarVisibility(rview, False) - rview.CameraParallelProjection = 1 - - Render(rview) - # ResetCamera(rview) - - def sync_color_config_to_state(self, index, context: ViewContext): - # Update state arrays directly without context manager to avoid recursive flush - self.state.varcolor[index] = context.config.colormap - self.state.varmin[index] = context.config.min_value - self.state.varmax[index] = context.config.max_value - self.state.uselogscale[index] = context.config.use_log_scale - self.state.override_range[index] = context.config.override_range - self.state.invert[index] = context.config.invert_colors - # Mark arrays as dirty to ensure UI updates - 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") - - def generate_colorbar_image(self, index): - """Generate colorbar image for a variable at given index. - - This uses the cached colorbar images based on the colormap name - and invert status. - """ - if index >= len(self.state.variables): - return + # Reactive behavior + self.config.watch( + ["color_value_min", "color_value_max"], + self.color_range_str_to_float, + ) + self.config.watch( + ["override_range", "color_range"], self.update_color_range, eager=True + ) + self.config.watch( + ["preset", "invert", "use_log_scale"], self.update_color_preset, eager=True + ) - var = self.state.variables[index] - context = self.registry.get_view(var) - if context is None: + # GUI + self._build_ui() + + def render(self): + if self.disable_render or not self.ctx.has(self.name): return + self.ctx[self.name].update() + + def set_camera_modified(self, fn): + self._observer = self.camera.AddObserver("ModifiedEvent", fn) + + @property + def camera(self): + return self.view.GetActiveCamera() + + def reset_camera(self): + self.view.InteractionMode = "2D" + self.view.ResetActiveCameraToNegativeZ() + self.view.ResetCamera(True, 0.9) + self.ctx[self.name].update() + + def update_color_preset(self, name, invert, log_scale): + self.config.preset = name + self.config.preset_img = get_cached_colorbar_image( + self.config.preset, + self.config.invert, + ) + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + if log_scale: + self.lut.MapControlPointsToLogSpace() + self.lut.UseLogScale = 1 + self.render() + + def color_range_str_to_float(self, color_value_min, color_value_max): + try: + min_value = float(color_value_min) + self.config.color_value_min_valid = not math.isnan(min_value) + except ValueError: + self.config.color_value_min_valid = False - # Get cached colorbar image based on colormap and invert status try: - image_data = get_cached_colorbar_image( - context.config.colormap, context.config.invert_colors + max_value = float(color_value_max) + self.config.color_value_max_valid = not math.isnan(max_value) + except ValueError: + self.config.color_value_max_valid = False + + if self.config.color_value_min_valid and self.config.color_value_max_valid: + self.config.color_range = [min_value, max_value] + + def update_color_range(self, *_): + if self.config.override_range: + skip_update = False + if math.isnan(self.config.color_range[0]): + skip_update = True + self.config.color_value_min_valid = False + + if math.isnan(self.config.color_range[1]): + skip_update = True + self.config.color_value_max_valid = False + + if skip_update: + return + + self.lut.RescaleTransferFunction(*self.config.color_range) + else: + self.representation.RescaleTransferFunctionToDataRange(False, True) + data_array = ( + self.source.views["atmosphere_data"] + .GetCellDataInformation() + .GetArray(self.variable_name) ) - # Update state with the cached image - self.state.colorbar_images[index] = image_data - self.state.dirty("colorbar_images") - except Exception as e: - print(f"Error getting cached colorbar image for {var}: {e}") - - def reset_camera(self, **kwargs): - if len(self.widgets) > 0 and len(self.state.variables) > 0: - var = self.state.variables[0] - context = self.registry.get_view(var) - if context and context.state.view_proxy: - context.state.view_proxy.ResetCamera(True, 0.9) - self.render_all_views() - - def render_all_views(self, **kwargs): - for widget in self.widgets: - widget.update() - - def render_view_by_index(self, index): - self.widgets[index].update() - - @trigger("view_gc") - def delete_render_view(self, ref_name): - view_to_delete = None - view_id = self.state[f"{ref_name}Id"] - for view in self.to_delete: - if view.GetGlobalIDAsString() == view_id: - view_to_delete = view - if view_to_delete is not None: - self.to_delete = [v for v in self.to_delete if v != view_to_delete] - Delete(view_to_delete) - - def compute_average(self, var, vtkdata=None): - if vtkdata is None: - data = self.source.views["atmosphere_data"] - vtkdata = sm.Fetch(data) - vardata = vtkdata.GetCellData().GetArray(var) - - # Check if area variable exists - area_array = vtkdata.GetCellData().GetArray("area") - return calculate_weighted_average(vardata, area_array) - - def compute_range(self, var, vtkdata=None): - if vtkdata is None: - data = self.source.views["atmosphere_data"] - vtkdata = sm.Fetch(data) - 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] - ) + if data_array: + data_range = data_array.GetRange() + self.config.color_range = data_range + self.config.color_value_min = str(data_range[0]) + self.config.color_value_max = str(data_range[1]) + self.config.color_value_min_valid = True + self.config.color_value_max_valid = True + self.lut.RescaleTransferFunction(*data_range) + self.render() + + def _build_ui(self): + with DivLayout( + self.server, template_name=self.name, connect_parent=False, classes="h-100" + ) as self.ui: + self.ui.root.classes = "h-100" + with v3.VCard( + variant="tonal", + style=( + "active_layout !== 'auto_layout' ? `height: calc(100% - ${top_padding}px;` : 'overflow-hidden'", + ), + tile=("active_layout !== 'auto_layout'",), + ): + with v3.VRow( + dense=True, + classes="ma-0 pa-0 bg-black opacity-90 d-flex align-center", + ): + view.create_size_menu(self.name, self.config) + with html.Div( + self.variable_name, + classes="text-subtitle-2 pr-2", + style="user-select: none;", + ): + with v3.VMenu(activator="parent"): + with v3.VList(density="compact", style="max-height: 40vh;"): + with self.config.provide_as("config"): + v3.VListItem( + subtitle=("name",), + v_for="name, idx in config.swap_group", + key="name", + click=( + self.ctrl.swap_variables, + "[config.variable, name]", + ), + ) + + v3.VIcon( + "mdi-lock-outline", + size="x-small", + v_show=("lock_views", False), + style="transform: scale(0.75);", + ) + + v3.VSpacer() + html.Div( + "t = {{ time_idx }}", + classes="text-caption px-1", + v_if="timestamps.length > 1", + ) + if self.variable_type == "m": + html.Div( + "[k = {{ midpoint_idx }}]", + classes="text-caption px-1", + v_if="midpoints.length > 1", + ) + if self.variable_type == "i": + html.Div( + "[k = {{ interface_idx }}]", + classes="text-caption px-1", + v_if="interfaces.length > 1", + ) + v3.VSpacer() + html.Div( + "avg = {{" + f"fields_avgs['{self.variable_name}']?.toExponential(2) || 'N/A'" + "}}", + classes="text-caption px-1", + ) + + with html.Div( + style=( + """ + { + aspectRatio: active_layout === 'auto_layout' ? aspect_ratio : null, + height: active_layout !== 'auto_layout' ? 'calc(100% - 2.4rem)' : null, + pointerEvents: lock_views ? 'none': null, + } + """, + ), + ): + pvw.VtkRemoteView( + self.view, interactive_ratio=1, ctx_name=self.name + ) - 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, + view.create_bottom_bar(self.config, self.update_color_preset) + + +class ViewManager(TrameComponent): + def __init__(self, server, source): + super().__init__(server) + self.source = source + self._var2view = {} + self._camera_sync_in_progress = False + self._last_vars = {} + self._active_configs = {} + + pvw.initialize(self.server) + + self.state.luts_normal = [ + {"name": k, "url": v["normal"], "safe": k in COLOR_BLIND_SAFE} + for k, v in COLORBAR_CACHE.items() + ] + self.state.luts_inverted = [ + {"name": k, "url": v["inverted"], "safe": k in COLOR_BLIND_SAFE} + for k, v in COLORBAR_CACHE.items() + ] + + # Sort lists + self.state.luts_normal.sort(key=lut_name) + self.state.luts_inverted.sort(key=lut_name) + + def refresh_ui(self, **_): + for view in self._var2view.values(): + view._build_ui() + + def reset_camera(self): + views = list(self._var2view.values()) + for view in views: + view.disable_render = True + + for view in views: + view.reset_camera() + + for view in views: + view.disable_render = False + + def render(self): + for view in list(self._var2view.values()): + view.render() + + def update_color_range(self): + for view in list(self._var2view.values()): + view.update_color_range() + + def get_view(self, variable_name, variable_type): + view = self._var2view.get(variable_name) + if view is None: + view = self._var2view.setdefault( + variable_name, + VariableView(self.server, self.source, variable_name, variable_type), ) - 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 - long = state.cliplong - lat = state.cliplat - tstamp = state.tstamp - time = 0.0 if len(self.state.timesteps) == 0 else self.state.timesteps[tstamp] - - 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 - self.to_delete.extend( - [self.registry.get_view(x).state.view_proxy for x in to_delete] - ) + view.set_camera_modified(self.sync_camera) + + return view + + def sync_camera(self, camera, *_): + if self._camera_sync_in_progress: + return + self._camera_sync_in_progress = True + + for var_view in self._var2view.values(): + cam = var_view.camera + if cam is camera: + continue + cam.DeepCopy(camera) + var_view.render() + + self._camera_sync_in_progress = False + + @controller.set("swap_variables") + def swap_variable(self, variable_a, variable_b): + config_a = self._active_configs[variable_a] + config_b = self._active_configs[variable_b] + config_a.order, config_b.order = config_b.order, config_a.order + config_a.size, config_b.size = config_b.size, config_a.size + config_a.offset, config_b.offset = config_b.offset, config_a.offset + config_a.break_row, config_b.break_row = config_b.break_row, config_a.break_row + + def apply_size(self, n_cols): + if not self._last_vars: + return + + if n_cols == 0: + # Auto based on group size + if self.state.layout_grouped: + for var_type in "smi": + var_names = self._last_vars[var_type] + total_size = len(var_names) + + if total_size == 0: + continue + + size = auto_size_to_col(total_size) + for name in var_names: + config = self.get_view(name, var_type).config + config.size = size - # Get area variable to calculate weighted average - data = self.source.views["atmosphere_data"] - vtkdata = sm.Fetch(data) - - # Use cached layout if provided, or fall back to saved layout in registry - layout_map = cached_layout if cached_layout else {} - - # If no cached layout, check if we have saved layout in registry - if not layout_map and hasattr(self.registry, "_saved_layout"): - # Convert saved layout array to variable-name-based map - temp_map = {} - for item in self.registry._saved_layout: - if isinstance(item, dict) and "i" in item: - idx = item["i"] - if hasattr(state, "variables") and idx < len(state.variables): - var_name = state.variables[idx] - temp_map[var_name] = { - "x": item.get("x", 0), - "y": item.get("y", 0), - "w": item.get("w", 4), - "h": item.get("h", 3), - } - layout_map = temp_map - - del self.state.views[:] - del self.state.layout[:] - del self.widgets[:] - sWidgets = [] - layout = [] - wdt = 4 - hgt = 3 - - view0 = None - 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 - - varrange = self.compute_range(var, vtkdata=vtkdata) - varavg = self.compute_average(var, vtkdata=vtkdata) - - view = None - context: ViewContext = self.registry.get_view(var) - if context is not 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) - context.state.view_proxy = view - # First time creating view for this context - # Context already has its configuration from previous sessions - # Just update range if not overridden (critical requirement) - if not context.config.override_range: - context.config.min_value = varrange[0] - context.config.max_value = varrange[1] - self.configure_new_view(var, context, self.source.views) - else: - # Trust the ViewContext - it's already configured - # Only update the color range if not overridden (critical requirement) - if not context.config.override_range: - context.config.min_value = varrange[0] - context.config.max_value = varrange[1] - - # Only refresh the display, don't change configuration - self.refresh_view_display(context) - else: - # Creating a completely new ViewContext - view = CreateRenderView() - - # Use defaults for new variables - default_colormap = self.get_default_colormap() - - config = ViewConfiguration( - variable=var, - colormap=default_colormap, - use_log_scale=False, # Default - invert_colors=False, # Default - min_value=varrange[0], # Always use current data range - max_value=varrange[1], - override_range=False, # Default to auto-range - ) - view_state = ViewState( - view_proxy=view, - ) - context = ViewContext(config, view_state, index) - view.UseColorPaletteForBackground = 0 - view.BackgroundColorMode = "Gradient" - self.registry.register_view(var, context) - self.configure_new_view(var, context, self.source.views) - - # Apply manual color range if override is enabled - if context.config.override_range: - coltrfunc = GetColorTransferFunction(var) - coltrfunc.RescaleTransferFunction( - context.config.min_value, context.config.max_value - ) - - context.index = index - # Set the computed average directly in trame state - self.state.varaverage[index] = varavg - self.state.dirty("varaverage") - self.sync_color_config_to_state(index, context) - self.generate_colorbar_image(index) - - if index == 0: - view0 = view else: - AddCameraLink(view, view0, f"viewlink{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 update_colormap(self, index, value): - """Update the colormap for a variable.""" - var = self.state.variables[index] - coltrfunc = GetColorTransferFunction(var) - context: ViewContext = self.registry.get_view(var) - - context.config.colormap = value - # Apply the preset - coltrfunc.ApplyPreset(context.config.colormap, True) - # Reapply inversion if it was enabled - if context.config.invert_colors: - coltrfunc.InvertTransferFunction() - - # Generate new colorbar image with updated colormap - self.generate_colorbar_image(index) - # Sync all color configuration changes back to state - self.sync_color_config_to_state(index, context) - self.render_view_by_index(index) - - def update_log_scale(self, index, value): - """Update the log scale setting for a variable.""" - var = self.state.variables[index] - coltrfunc = GetColorTransferFunction(var) - context: ViewContext = self.registry.get_view(var) - - context.config.use_log_scale = value - if context.config.use_log_scale: - coltrfunc.MapControlPointsToLogSpace() - coltrfunc.UseLogScale = 1 + size = auto_size_to_col(len(self._active_configs)) + for config in self._active_configs.values(): + config.size = size else: - coltrfunc.MapControlPointsToLinearSpace() - coltrfunc.UseLogScale = 0 - # Note: We don't regenerate the colorbar image here because the color gradient - # itself doesn't change with log scale - only the data mapping changes. - # The colorbar always shows a linear color progression. - - # Sync all color configuration changes back to state - self.sync_color_config_to_state(index, context) - self.render_view_by_index(index) - - def update_invert_colors(self, index, value): - """Update the color inversion setting for a variable.""" - var = self.state.variables[index] - coltrfunc = GetColorTransferFunction(var) - context: ViewContext = self.registry.get_view(var) - - context.config.invert_colors = value - coltrfunc.InvertTransferFunction() - # Generate new colorbar image when colors are inverted - self.generate_colorbar_image(index) - - # Sync all color configuration changes back to state - self.sync_color_config_to_state(index, context) - self.render_view_by_index(index) - - def update_scalar_bars(self, event=None): - # Always hide ParaView scalar bars - using custom HTML colorbar - # The HTML colorbar is always visible, no toggle needed - for _, context in self.registry.items(): - view = context.state.view_proxy - context.state.data_representation.SetScalarBarVisibility(view, False) - self.render_all_views() - - def set_manual_color_range(self, index, min, max): - var = self.state.variables[index] - context: ViewContext = self.registry.get_view(var) - context.config.override_range = True - context.config.min_value = float(min) - context.config.max_value = float(max) - # Sync all changes back to state - self.sync_color_config_to_state(index, context) - # Update color transfer function - coltrfunc = GetColorTransferFunction(var) - coltrfunc.RescaleTransferFunction(float(min), float(max)) - # Note: colorbar image doesn't change with range, only data mapping changes - self.render_view_by_index(index) - - def revert_to_auto_color_range(self, index): - var = self.state.variables[index] - # Get colors from main file - varrange = self.compute_range(var) - context: ViewContext = self.registry.get_view(var) - context.config.override_range = False - context.config.min_value = varrange[0] - context.config.max_value = varrange[1] - # Sync all changes back to state - self.sync_color_config_to_state(index, context) - # Rescale transfer function to data range - context.state.data_representation.RescaleTransferFunctionToDataRange( - False, True - ) - # Note: colorbar image doesn't change with range, only data mapping changes - self.render_all_views() - - def zoom_in(self, index=0): - var = self.state.variables[index] - context: ViewContext = self.registry.get_view(var) - rview = context.state.view_proxy - rview.CameraParallelScale *= 0.95 - self.render_all_views() - - def zoom_out(self, index=0): - var = self.state.variables[index] - context: ViewContext = self.registry.get_view(var) - rview = context.state.view_proxy - rview.CameraParallelScale *= 1.05 - self.render_all_views() - - def pan_camera(self, dir, factor, index=0): - var = self.state.variables[index] - context: ViewContext = self.registry.get_view(var) - rview = context.state.view_proxy - extents = self.source.moveextents - move = ( - (extents[1] - extents[0]) * 0.05, - (extents[3] - extents[2]) * 0.05, - (extents[5] - extents[4]) * 0.05, - ) + # uniform size + for config in self._active_configs.values(): + config.size = COL_SIZE_LOOKUP[n_cols] + + def build_auto_layout(self, variables=None): + if variables is None: + variables = self._last_vars + + self._last_vars = variables + + # Create UI based on variables + self.state.swap_groups = {} + with DivLayout(self.server, template_name="auto_layout") as self.ui: + if self.state.layout_grouped: + with v3.VCol(classes="pa-1"): + for var_type in "smi": + var_names = variables[var_type] + total_size = len(var_names) + + if total_size == 0: + continue + + with v3.VAlert( + border="start", + classes="pr-1 py-1 pl-3 mb-1", + variant="flat", + border_color=TYPE_COLOR[var_type], + ): + with v3.VRow(dense=True): + for name in var_names: + view = self.get_view(name, var_type) + view.config.swap_group = sorted( + [n for n in var_names if n != name] + ) + with view.config.provide_as("config"): + v3.VCol( + v_if="config.break_row", + cols=12, + classes="pa-0", + style=("`order: ${config.order};`",), + ) + # For flow handling + with v3.Template(v_if="!config.size"): + v3.VCol( + v_for="i in config.offset", + key="i", + style=("{ order: config.order }",), + ) + with v3.VCol( + offset=("config.offset * config.size",), + cols=("config.size",), + style=("`order: ${config.order};`",), + ): + client.ServerTemplate(name=view.name) + else: + all_names = [name for names in variables.values() for name in names] + with v3.VRow(dense=True, classes="pa-2"): + for var_type in "smi": + var_names = variables[var_type] + for name in var_names: + view = self.get_view(name, var_type) + view.config.swap_group = sorted( + [n for n in all_names if n != name] + ) + with view.config.provide_as("config"): + v3.VCol( + v_if="config.break_row", + cols=12, + classes="pa-0", + style=("`order: ${config.order};`",), + ) + + # For flow handling + with v3.Template(v_if="!config.size"): + v3.VCol( + v_for="i in config.offset", + key="i", + style=("{ order: config.order }",), + ) + with v3.VCol( + offset=( + "config.size ? config.offset * config.size : 0", + ), + cols=("config.size",), + style=("`order: ${config.order};`",), + ): + client.ServerTemplate(name=view.name) + + # Assign any missing order + self._active_configs = {} + existed_order = set() + order_max = 0 + orders_to_update = [] + for var_type in "smi": + var_names = variables[var_type] + for name in var_names: + config = self.get_view(name, var_type).config + self._active_configs[name] = config + if config.order: + order_max = max(order_max, config.order) + assert config.order not in existed_order, "Order already assigned" + existed_order.add(config.order) + else: + orders_to_update.append(config) - pos = rview.CameraPosition - foc = rview.CameraFocalPoint - pos[dir] += move[dir] if factor > 0 else -move[dir] - foc[dir] += move[dir] if factor > 0 else -move[dir] - rview.CameraPosition = pos - rview.CameraFocalPoint = foc - self.render_all_views() + next_order = order_max + 1 + for config in orders_to_update: + config.order = next_order + next_order += 1 diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py deleted file mode 100644 index 7e26461..0000000 --- a/src/e3sm_quickview/view_manager2.py +++ /dev/null @@ -1,521 +0,0 @@ -import math - -from trame.app import TrameComponent -from trame.ui.html import DivLayout -from trame.widgets import paraview as pvw, vuetify3 as v3, client, html -from trame.decorators import controller - -from trame_dataclass.core import StateDataModel - -from paraview import simple - -from e3sm_quickview.components import view -from e3sm_quickview.utils.color import get_cached_colorbar_image, COLORBAR_CACHE -from e3sm_quickview.presets import COLOR_BLIND_SAFE - - -def auto_size_to_col(size): - if size == 1: - return 12 - - if size >= 8 and size % 2 == 0: - return 3 - - if size % 3 == 0: - return 4 - - if size % 2 == 0: - return 6 - - return auto_size_to_col(size + 1) - - -COL_SIZE_LOOKUP = { - 0: auto_size_to_col, - 1: 12, - 2: 6, - 3: 4, - 4: 3, - 6: 2, - 12: 1, - "flow": None, -} - -TYPE_COLOR = { - "s": "success", - "i": "info", - "m": "warning", -} - - -def lut_name(element): - return element.get("name").lower() - - -class ViewConfiguration(StateDataModel): - variable: str - preset: str = "Inferno (matplotlib)" - preset_img: str - invert: bool = False - color_blind: bool = False - use_log_scale: bool = False - color_value_min: str = "0" - color_value_max: str = "1" - color_value_min_valid: bool = True - color_value_max_valid: bool = True - color_range: list[float] = (0, 1) - override_range: bool = False - order: int = 0 - size: int = 4 - offset: int = 0 - break_row: bool = False - menu: bool = False - swap_group: list[str] - search: str | None - - -class VariableView(TrameComponent): - def __init__(self, server, source, variable_name, variable_type): - super().__init__(server) - self.config = ViewConfiguration(server, variable=variable_name) - self.source = source - self.variable_name = variable_name - self.variable_type = variable_type - self.name = f"view_{self.variable_name}" - self.view = simple.CreateRenderView() - self.view.GetRenderWindow().SetOffScreenRendering(True) - self.view.InteractionMode = "2D" - self.view.OrientationAxesVisibility = 0 - self.view.UseColorPaletteForBackground = 0 - self.view.BackgroundColorMode = "Gradient" - self.view.CameraParallelProjection = 1 - self.view.Size = 0 # make the interactive widget non responsive - self.representation = simple.Show( - proxy=source.views["atmosphere_data"], - view=self.view, - ) - - # Lookup table color management - simple.ColorBy(self.representation, ("CELLS", variable_name)) - self.lut = simple.GetColorTransferFunction(variable_name) - self.lut.NanOpacity = 0.0 - - self.view.ResetActiveCameraToNegativeZ() - self.view.ResetCamera(True, 0.9) - self.disable_render = False - - # Add annotation to the view - # - continents - globe = source.views["continents"] - repG = simple.Show(globe, self.view) - simple.ColorBy(repG, None) - repG.SetRepresentationType("Wireframe") - repG.RenderLinesAsTubes = 1 - repG.LineWidth = 1.0 - repG.AmbientColor = [0.67, 0.67, 0.67] - repG.DiffuseColor = [0.67, 0.67, 0.67] - self.rep_globe = repG - # - gridlines - annot = source.views["grid_lines"] - repAn = simple.Show(annot, self.view) - repAn.SetRepresentationType("Wireframe") - repAn.AmbientColor = [0.67, 0.67, 0.67] - repAn.DiffuseColor = [0.67, 0.67, 0.67] - repAn.Opacity = 0.4 - self.rep_grid = repAn - - # Reactive behavior - self.config.watch( - ["color_value_min", "color_value_max"], - self.color_range_str_to_float, - ) - self.config.watch( - ["override_range", "color_range"], self.update_color_range, eager=True - ) - self.config.watch( - ["preset", "invert", "use_log_scale"], self.update_color_preset, eager=True - ) - - # GUI - self._build_ui() - - def render(self): - if self.disable_render or not self.ctx.has(self.name): - return - self.ctx[self.name].update() - - def set_camera_modified(self, fn): - self._observer = self.camera.AddObserver("ModifiedEvent", fn) - - @property - def camera(self): - return self.view.GetActiveCamera() - - def reset_camera(self): - self.view.InteractionMode = "2D" - self.view.ResetActiveCameraToNegativeZ() - self.view.ResetCamera(True, 0.9) - self.ctx[self.name].update() - - def update_color_preset(self, name, invert, log_scale): - self.config.preset = name - self.config.preset_img = get_cached_colorbar_image( - self.config.preset, - self.config.invert, - ) - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() - if log_scale: - self.lut.MapControlPointsToLogSpace() - self.lut.UseLogScale = 1 - self.render() - - def color_range_str_to_float(self, color_value_min, color_value_max): - try: - min_value = float(color_value_min) - self.config.color_value_min_valid = not math.isnan(min_value) - except ValueError: - self.config.color_value_min_valid = False - - try: - max_value = float(color_value_max) - self.config.color_value_max_valid = not math.isnan(max_value) - except ValueError: - self.config.color_value_max_valid = False - - if self.config.color_value_min_valid and self.config.color_value_max_valid: - self.config.color_range = [min_value, max_value] - - def update_color_range(self, *_): - if self.config.override_range: - skip_update = False - if math.isnan(self.config.color_range[0]): - skip_update = True - self.config.color_value_min_valid = False - - if math.isnan(self.config.color_range[1]): - skip_update = True - self.config.color_value_max_valid = False - - if skip_update: - return - - self.lut.RescaleTransferFunction(*self.config.color_range) - else: - self.representation.RescaleTransferFunctionToDataRange(False, True) - data_array = ( - self.source.views["atmosphere_data"] - .GetCellDataInformation() - .GetArray(self.variable_name) - ) - if data_array: - data_range = data_array.GetRange() - self.config.color_range = data_range - self.config.color_value_min = str(data_range[0]) - self.config.color_value_max = str(data_range[1]) - self.config.color_value_min_valid = True - self.config.color_value_max_valid = True - self.lut.RescaleTransferFunction(*data_range) - self.render() - - def _build_ui(self): - with DivLayout( - self.server, template_name=self.name, connect_parent=False, classes="h-100" - ) as self.ui: - self.ui.root.classes = "h-100" - with v3.VCard( - variant="tonal", - style=( - "active_layout !== 'auto_layout' ? `height: calc(100% - ${top_padding}px;` : 'overflow-hidden'", - ), - tile=("active_layout !== 'auto_layout'",), - ): - with v3.VRow( - dense=True, - classes="ma-0 pa-0 bg-black opacity-90 d-flex align-center", - ): - view.create_size_menu(self.name, self.config) - with html.Div( - self.variable_name, - classes="text-subtitle-2 pr-2", - style="user-select: none;", - ): - with v3.VMenu(activator="parent"): - with v3.VList(density="compact", style="max-height: 40vh;"): - with self.config.provide_as("config"): - v3.VListItem( - subtitle=("name",), - v_for="name, idx in config.swap_group", - key="name", - click=( - self.ctrl.swap_variables, - "[config.variable, name]", - ), - ) - - v3.VIcon( - "mdi-lock-outline", - size="x-small", - v_show=("lock_views", False), - style="transform: scale(0.75);", - ) - - v3.VSpacer() - html.Div( - "t = {{ time_idx }}", - classes="text-caption px-1", - v_if="timestamps.length > 1", - ) - if self.variable_type == "m": - html.Div( - "[k = {{ midpoint_idx }}]", - classes="text-caption px-1", - v_if="midpoints.length > 1", - ) - if self.variable_type == "i": - html.Div( - "[k = {{ interface_idx }}]", - classes="text-caption px-1", - v_if="interfaces.length > 1", - ) - v3.VSpacer() - html.Div( - "avg = {{" - f"fields_avgs['{self.variable_name}']?.toExponential(2) || 'N/A'" - "}}", - classes="text-caption px-1", - ) - - with html.Div( - style=( - """ - { - aspectRatio: active_layout === 'auto_layout' ? aspect_ratio : null, - height: active_layout !== 'auto_layout' ? 'calc(100% - 2.4rem)' : null, - pointerEvents: lock_views ? 'none': null, - } - """, - ), - ): - pvw.VtkRemoteView( - self.view, interactive_ratio=1, ctx_name=self.name - ) - - view.create_bottom_bar(self.config, self.update_color_preset) - - -class ViewManager(TrameComponent): - def __init__(self, server, source): - super().__init__(server) - self.source = source - self._var2view = {} - self._camera_sync_in_progress = False - self._last_vars = {} - self._active_configs = {} - - pvw.initialize(self.server) - - self.state.luts_normal = [ - {"name": k, "url": v["normal"], "safe": k in COLOR_BLIND_SAFE} - for k, v in COLORBAR_CACHE.items() - ] - self.state.luts_inverted = [ - {"name": k, "url": v["inverted"], "safe": k in COLOR_BLIND_SAFE} - for k, v in COLORBAR_CACHE.items() - ] - - # Sort lists - self.state.luts_normal.sort(key=lut_name) - self.state.luts_inverted.sort(key=lut_name) - - def refresh_ui(self, **_): - for view in self._var2view.values(): - view._build_ui() - - def reset_camera(self): - views = list(self._var2view.values()) - for view in views: - view.disable_render = True - - for view in views: - view.reset_camera() - - for view in views: - view.disable_render = False - - def render(self): - for view in list(self._var2view.values()): - view.render() - - def update_color_range(self): - for view in list(self._var2view.values()): - view.update_color_range() - - def get_view(self, variable_name, variable_type): - view = self._var2view.get(variable_name) - if view is None: - view = self._var2view.setdefault( - variable_name, - VariableView(self.server, self.source, variable_name, variable_type), - ) - view.set_camera_modified(self.sync_camera) - - return view - - def sync_camera(self, camera, *_): - if self._camera_sync_in_progress: - return - self._camera_sync_in_progress = True - - for var_view in self._var2view.values(): - cam = var_view.camera - if cam is camera: - continue - cam.DeepCopy(camera) - var_view.render() - - self._camera_sync_in_progress = False - - @controller.set("swap_variables") - def swap_variable(self, variable_a, variable_b): - config_a = self._active_configs[variable_a] - config_b = self._active_configs[variable_b] - config_a.order, config_b.order = config_b.order, config_a.order - config_a.size, config_b.size = config_b.size, config_a.size - config_a.offset, config_b.offset = config_b.offset, config_a.offset - config_a.break_row, config_b.break_row = config_b.break_row, config_a.break_row - - def apply_size(self, n_cols): - if not self._last_vars: - return - - if n_cols == 0: - # Auto based on group size - if self.state.layout_grouped: - for var_type in "smi": - var_names = self._last_vars[var_type] - total_size = len(var_names) - - if total_size == 0: - continue - - size = auto_size_to_col(total_size) - for name in var_names: - config = self.get_view(name, var_type).config - config.size = size - - else: - size = auto_size_to_col(len(self._active_configs)) - for config in self._active_configs.values(): - config.size = size - else: - # uniform size - for config in self._active_configs.values(): - config.size = COL_SIZE_LOOKUP[n_cols] - - def build_auto_layout(self, variables=None): - if variables is None: - variables = self._last_vars - - self._last_vars = variables - - # Create UI based on variables - self.state.swap_groups = {} - with DivLayout(self.server, template_name="auto_layout") as self.ui: - if self.state.layout_grouped: - with v3.VCol(classes="pa-1"): - for var_type in "smi": - var_names = variables[var_type] - total_size = len(var_names) - - if total_size == 0: - continue - - with v3.VAlert( - border="start", - classes="pr-1 py-1 pl-3 mb-1", - variant="flat", - border_color=TYPE_COLOR[var_type], - ): - with v3.VRow(dense=True): - for name in var_names: - view = self.get_view(name, var_type) - view.config.swap_group = sorted( - [n for n in var_names if n != name] - ) - with view.config.provide_as("config"): - v3.VCol( - v_if="config.break_row", - cols=12, - classes="pa-0", - style=("`order: ${config.order};`",), - ) - # For flow handling - with v3.Template(v_if="!config.size"): - v3.VCol( - v_for="i in config.offset", - key="i", - style=("{ order: config.order }",), - ) - with v3.VCol( - offset=("config.offset * config.size",), - cols=("config.size",), - style=("`order: ${config.order};`",), - ): - client.ServerTemplate(name=view.name) - else: - all_names = [name for names in variables.values() for name in names] - with v3.VRow(dense=True, classes="pa-2"): - for var_type in "smi": - var_names = variables[var_type] - for name in var_names: - view = self.get_view(name, var_type) - view.config.swap_group = sorted( - [n for n in all_names if n != name] - ) - with view.config.provide_as("config"): - v3.VCol( - v_if="config.break_row", - cols=12, - classes="pa-0", - style=("`order: ${config.order};`",), - ) - - # For flow handling - with v3.Template(v_if="!config.size"): - v3.VCol( - v_for="i in config.offset", - key="i", - style=("{ order: config.order }",), - ) - with v3.VCol( - offset=( - "config.size ? config.offset * config.size : 0", - ), - cols=("config.size",), - style=("`order: ${config.order};`",), - ): - client.ServerTemplate(name=view.name) - - # Assign any missing order - self._active_configs = {} - existed_order = set() - order_max = 0 - orders_to_update = [] - for var_type in "smi": - var_names = variables[var_type] - for name in var_names: - config = self.get_view(name, var_type).config - self._active_configs[name] = config - if config.order: - order_max = max(order_max, config.order) - assert config.order not in existed_order, "Order already assigned" - existed_order.add(config.order) - else: - orders_to_update.append(config) - - next_order = order_max + 1 - for config in orders_to_update: - config.order = next_order - next_order += 1 From 00ead1bbf5aa6a9fb6d6b9410392d06b194fc465 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Wed, 12 Nov 2025 15:33:18 -0800 Subject: [PATCH 2/2] chore: removing vue2(old) CI scripts --- .github/workflows/package.yml | 4 ++-- .github/workflows/test.yml | 2 +- pyproject.toml | 2 +- scripts/setup_tauri.sh | 14 +++++++++----- scripts/setup_tauri2.sh | 30 ------------------------------ 5 files changed, 13 insertions(+), 39 deletions(-) delete mode 100755 scripts/setup_tauri2.sh diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index c832eeb..67ab635 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -59,8 +59,8 @@ jobs: shell: bash -l {0} - name: Package using PyInstaller run: | - chmod +x scripts/setup_tauri2.sh - ./scripts/setup_tauri2.sh + chmod +x scripts/setup_tauri.sh + ./scripts/setup_tauri.sh shell: bash -l {0} - name: setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d5561e6..d30a7ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,5 +30,5 @@ jobs: - name: Run help command run: | - python -m e3sm_quickview.app2 --help + python -m e3sm_quickview.app --help shell: bash -l {0} diff --git a/pyproject.toml b/pyproject.toml index d1a9590..07fdb97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ packages = [ [project.scripts] quickview-vue2 = "e3sm_quickview.app:main" -quickview = "e3sm_quickview.app2:main" +quickview = "e3sm_quickview.app:main" [tool.ruff.lint.per-file-ignores] # Ignore star import issues in ParaView plugins diff --git a/scripts/setup_tauri.sh b/scripts/setup_tauri.sh index 4210975..43fe389 100755 --- a/scripts/setup_tauri.sh +++ b/scripts/setup_tauri.sh @@ -10,17 +10,21 @@ python -m PyInstaller --clean --noconfirm \ --name server --hidden-import pkgutil \ --collect-all trame \ --collect-all trame_client \ - --collect-all trame_components \ - --collect-all trame_grid_layout \ + --collect-all trame_dataclass \ --collect-all trame_vtk \ --collect-all trame_vuetify \ --collect-all trame_tauri \ --collect-all pyproj \ --collect-all netCDF4 \ --collect-all paraview \ - --collect-all quickview \ + --collect-all e3sm_quickview \ --hidden-import pkgutil \ --add-binary="$(which pvpython):." \ - quickview/app.py + src/e3sm_quickview/app2.py -python -m trame.tools.www --output ./src-tauri/www --client-type vue2 +# Generate trame www + quickview +python -m trame.tools.www --output ./src-tauri/www +python -m trame.tools.www --output ./src-tauri/www e3sm_quickview.module + +# Precompile install to speedup start (maybe?) +./src-tauri/server/server --timeout 1 --server diff --git a/scripts/setup_tauri2.sh b/scripts/setup_tauri2.sh deleted file mode 100755 index 43fe389..0000000 --- a/scripts/setup_tauri2.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -set -e -set -x - -pip install . - -python -m PyInstaller --clean --noconfirm \ - --distpath src-tauri \ - --name server --hidden-import pkgutil \ - --collect-all trame \ - --collect-all trame_client \ - --collect-all trame_dataclass \ - --collect-all trame_vtk \ - --collect-all trame_vuetify \ - --collect-all trame_tauri \ - --collect-all pyproj \ - --collect-all netCDF4 \ - --collect-all paraview \ - --collect-all e3sm_quickview \ - --hidden-import pkgutil \ - --add-binary="$(which pvpython):." \ - src/e3sm_quickview/app2.py - -# Generate trame www + quickview -python -m trame.tools.www --output ./src-tauri/www -python -m trame.tools.www --output ./src-tauri/www e3sm_quickview.module - -# Precompile install to speedup start (maybe?) -./src-tauri/server/server --timeout 1 --server