diff --git a/docs/setup/for_app_developers.md b/docs/setup/for_app_developers.md index 5433f53..99b909f 100644 --- a/docs/setup/for_app_developers.md +++ b/docs/setup/for_app_developers.md @@ -38,7 +38,7 @@ python -m quickview.app --data /path/to/your/data.nc --conn /path/to/connectivit To launch server only (no browser popup), use ``` -python --server -m quickview.app --data /path/to/your/data.nc --conn /path/to/connectivity.nc +python -m quickview.app --data /path/to/your/data.nc --conn /path/to/connectivity.nc --server ``` ---- diff --git a/pyproject.toml b/pyproject.toml index 49286db..0a5ca82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pyproj>=3.6.1", "netCDF4>=1.6.5", "pyinstaller", + "trame-dataclass", ] requires-python = ">=3.13" readme = "README.md" @@ -42,6 +43,7 @@ quickview = [ [project.scripts] quickview = "quickview.app:main" +quickview2 = "quickview.app2:main" [tool.ruff.lint.per-file-ignores] # Ignore star import issues in ParaView plugins diff --git a/quickview/app.py b/quickview/app.py index 13c563c..16a01b4 100644 --- a/quickview/app.py +++ b/quickview/app.py @@ -9,7 +9,7 @@ from quickview.interface import EAMApp -def serve(): +def main(): parser = argparse.ArgumentParser( prog="eamapp.py", description="Trame based app for visualizing EAM data" ) @@ -57,4 +57,4 @@ def serve(): if __name__ == "__main__": - serve() + main() diff --git a/quickview/app2.py b/quickview/app2.py new file mode 100644 index 0000000..5dda742 --- /dev/null +++ b/quickview/app2.py @@ -0,0 +1,441 @@ +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 +from trame.decorators import controller, change, trigger, life_cycle + +from quickview import module as qv_module +from quickview.assets import ASSETS +from quickview.components import doc, file_browser, css, toolbars, dialogs, drawers +from quickview.pipeline import EAMVisSource +from quickview.utils import compute, js, constants, cli +from 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, + "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=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: + 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 + for value in toolbars.VALUES: + v3.VToolbar( + v_show=js.is_active(value), + density=toolbars.DENSITY[value], + **toolbars.DEFAULT_STYLES, + ) + + # 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")) + + # 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") + 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": { + "preset": config.preset, + "invert": config.invert, + "use_log_scale": config.use_log_scale, + "color_range": config.color_range, + "override_range": config.override_range, + "order": config.order, + "size": config.size, + }, + } + ) + + 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( + "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 + ) + + +# ------------------------------------------------------------------------- +# Standalone execution +# ------------------------------------------------------------------------- +def main(): + app = EAMApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/quickview/assets/__init__.py b/quickview/assets/__init__.py new file mode 100644 index 0000000..6a48d5b --- /dev/null +++ b/quickview/assets/__init__.py @@ -0,0 +1,5 @@ +from trame.assets.local import LocalFileManager + +ASSETS = LocalFileManager(__file__) +ASSETS.url("icon", "small-icon.png") +ASSETS.url("banner", "banner.jpg") diff --git a/quickview/assets/banner.jpg b/quickview/assets/banner.jpg new file mode 100644 index 0000000..7ca3a16 Binary files /dev/null and b/quickview/assets/banner.jpg differ diff --git a/quickview/assets/small-icon.png b/quickview/assets/small-icon.png new file mode 100644 index 0000000..3a0220c Binary files /dev/null and b/quickview/assets/small-icon.png differ diff --git a/quickview/components/__init__.py b/quickview/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quickview/components/css.py b/quickview/components/css.py new file mode 100644 index 0000000..9e5c9de --- /dev/null +++ b/quickview/components/css.py @@ -0,0 +1,17 @@ +NAV_BAR_TOP = ("`position:fixed;top:0;width:${compact_drawer ? '55' : '219'}px;`",) +NAV_BAR_BOTTOM = ( + "`position:fixed;bottom:0;width:${compact_drawer ? '55' : '219'}px;`", +) + +TOOLBARS_FIXED_OVERLAY = ( + "`position:fixed;top:0;width:${Math.floor(main_size?.size?.width || 0)}px;z-index:1;`", +) + + +FULLSCREEN_OVERLAY = "position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:1000;" + +DIALOG_STYLES = { + "contained": True, + "max_width": "80vw", + "persistent": True, +} diff --git a/quickview/components/dialogs.py b/quickview/components/dialogs.py new file mode 100644 index 0000000..1a50ea7 --- /dev/null +++ b/quickview/components/dialogs.py @@ -0,0 +1,72 @@ +from trame.widgets import html, vuetify3 as v3 + +from quickview.components import css +from quickview.utils import js + + +class FileOpen(html.Div): + def __init__(self, file_browser): + super().__init__(style=css.FULLSCREEN_OVERLAY) + with self: + with v3.VDialog( + model_value=(js.is_active("load-data"),), + **css.DIALOG_STYLES, + ): + file_browser.ui() + + +class StateDownload(html.Div): + def __init__(self): + super().__init__(style=css.FULLSCREEN_OVERLAY) + with self: + with v3.VDialog( + model_value=("show_export_dialog", False), + **css.DIALOG_STYLES, + ): + with v3.VCard(title="Download QuickView State file", rounded="lg"): + v3.VDivider() + with v3.VCardText(): + with v3.VRow(dense=True): + with v3.VCol(cols=12): + html.Label( + "Filename", + classes="text-subtitle-1 font-weight-medium mb-2 d-block", + ) + v3.VTextField( + v_model=( + "download_name", + "quickview-state.json", + ), + density="comfortable", + placeholder="Enter the filename to download", + variant="outlined", + ) + with v3.VRow(dense=True): + with v3.VCol(cols=12): + html.Label( + "Comments", + classes="text-subtitle-1 font-weight-medium mb-2 d-block", + ) + v3.VTextarea( + v_model=("export_comment", ""), + density="comfortable", + placeholder="Remind yourself what that state captures", + rows="4", + variant="outlined", + ) + with v3.VCardActions(): + v3.VSpacer() + v3.VBtn( + text="Cancel", + click="show_export_dialog=false", + classes="text-none", + variant="flat", + color="surface", + ) + v3.VBtn( + text="Download", + classes="text-none", + variant="flat", + color="primary", + click="show_export_dialog=false;utils.download(download_name, trigger('download_state'), 'application/json')", + ) diff --git a/quickview/components/doc.py b/quickview/components/doc.py new file mode 100644 index 0000000..5aa2987 --- /dev/null +++ b/quickview/components/doc.py @@ -0,0 +1,235 @@ +from trame.widgets import vuetify3 as v3, html +from quickview.assets import ASSETS + + +# ----------------------------------------------------------------------------- +# Tools +# ----------------------------------------------------------------------------- + + +class Tool(v3.VListItem): + def __init__(self, icon, title, description): + super().__init__(classes="px-0") + with self: + with v3.VListItemTitle(): + with html.P(classes="text-body-2 font-weight-bold pb-2") as p: + v3.VIcon(classes="mr-2", size="small", icon=icon) + p.add_child(title) + with v3.VListItemSubtitle(): + html.P(description, classes="ps-7") + + +class ToolFileLoading(Tool): + def __init__(self): + super().__init__( + icon="mdi-file-document-outline", + title="File loading", + description="Load files to explore. Those could be simulation and connectivity files or even a state file pointing to those files.", + ) + + +class ToolFieldSelection(Tool): + def __init__(self): + super().__init__( + icon="mdi-list-status", + title="Fields selection", + description=""" + Select the variables to visualize. You need to load files prior any field selection. + """, + ) + + +class ToolResetCamera(Tool): + def __init__(self): + super().__init__( + icon="mdi-crop-free", + title="Reset camera", + description="Recenter the visualizations to the full data.", + ) + + +class ToolStateImportExport(Tool): + def __init__(self): + super().__init__( + icon="mdi-folder-arrow-left-right-outline", + title="State import/export", + description="Export the application state into a small text file. The same file can then be imported to restore that application state.", + ) + + +class ToolMapProjection(Tool): + def __init__(self): + super().__init__( + icon="mdi-earth", + title="Map Projection", + description="Select projection to use for the visualizations. (Cylindrical Equidistant, Robinson, Mollweide)", + ) + + +class ToolLayoutManagement(Tool): + def __init__(self): + super().__init__( + icon="mdi-collage", + title="Layout management", + description="Toggle layout toolbar for adjusting aspect-ratio, width and grouping options.", + ) + + +class ToolCropping(Tool): + def __init__(self): + super().__init__( + icon="mdi-crop", + title="Lat/Long cropping", + description="Toggle cropping toolbar for adjusting spacial bounds.", + ) + + +class ToolDataSelection(Tool): + def __init__(self): + super().__init__( + icon="mdi-tune-variant", + title="Slice selection", + description="Toggle data selection toolbar for selecting a given layer, midpoint or time.", + ) + + +class ToolAnimation(Tool): + def __init__(self): + super().__init__( + icon="mdi-movie-open-cog-outline", + title="Animation controls", + description="Toggle animation toolbar.", + ) + + +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- + + +class Title(html.P): + def __init__(self, content=None): + super().__init__( + content, classes="mt-6 mb-4 text-h6 font-weight-bold text-medium-emphasis" + ) + + +class Paragraph(html.P): + def __init__(self, content): + super().__init__(content, classes="mt-4 mb-6 text-body-1") + + +# ----------------------------------------------------------------------------- + + +class LandingPage(v3.VContainer): + def __init__(self): + super().__init__(classes="pa-6 pa-md-12") + + with self: + with html.P( + classes="mt-2 text-h5 font-weight-bold text-sm-h4 text-medium-emphasis" + ): + html.A( + "QuickView", + classes="text-primary text-decoration-none", + href="https://quickview.readthedocs.io/en/latest/", + target="_blank", + ) + + Paragraph(""" + EAM QuickView is an open-source, interactive visualization + tool designed for scientists working with the atmospheric component + of the Energy Exascale Earth System Model (E3SM), + known as the E3SM Atmosphere Model (EAM). + Its Python- and Trame-based + Graphical User Interface (GUI) provides intuitive access to ParaView's powerful analysis + and visualization capabilities, without the steep learning curve. + """) + + v3.VImg( + classes="rounded-lg", + src=ASSETS.banner, + ) + + Title("Getting started") + + with v3.VRow(): + with v3.VCol(cols=6): + ToolFileLoading() + ToolFieldSelection() + ToolMapProjection() + ToolResetCamera() + + with v3.VCol(cols=6): + ToolLayoutManagement() + ToolCropping() + ToolDataSelection() + ToolAnimation() + ToolStateImportExport() + + Title("Simulation Files") + + Paragraph( + """ + QuickView has been developed using EAM's history output on + the physics grids (pg2 grids) written by EAMv2, v3, and an + intermediate version towards v4 (EAMxx). + Those sample output files can be found on Zenodo. + """ + ) + Paragraph( + """ + Developers and users of EAM often use tools like NCO and CDO + or write their own scripts to calculate time averages and/or + select a subset of variables from the original model output. + For those use cases, we clarify below the features of the data + format that QuickView expects in order to properly read and + visualize the simulation data. + """ + ) + + Title("Connectivity Files") + + Paragraph( + """ + The horizontal grids used by EAM are cubed spheres. + Since these are unstructed grids, QuickView needs + to know how to map data to the globe. Therefore, + for each simulation data file, a "connectivity file" + needs to be provided. + """ + ) + + Paragraph( + """ + In EAMv2, v3, and v4, most of the variables + (physical quantities) are written out on a + "physics grid" (also referred to as "physgrid", + "FV grid", or "control volume mesh") described + in Hannah et al. (2021). The naming convention + for such grids is ne*pg2, with * being a number, + e.g., 4, 30, 120, 256. Further details about EAM's + cubed-sphere grids can be found in EAM's documention, + for example in this overview and this description. + """ + ) + Paragraph( + """ + Future versions of QuickView will also support the + cubed-sphere meshes used by EAM's dynamical core, + i.e., the ne*np4 grids (also referred to as + "native grids" or "GLL grids"). + """ + ) + + Title("Project Background") + + Paragraph( + """ + The lead developer of EAM QuickView is Abhishek Yenpure (abhi.yenpure@kitware.com) + at Kitware, Inc.. Other key contributors at Kitware, Inc. include Berk Geveci and + Sebastien Jourdain. Key contributors on the atmospheric science side are Hui Wan + and Kai Zhang at Pacific Northwest National Laboratory. + """ + ) diff --git a/quickview/components/drawers.py b/quickview/components/drawers.py new file mode 100644 index 0000000..0bf915b --- /dev/null +++ b/quickview/components/drawers.py @@ -0,0 +1,134 @@ +from trame.decorators import change +from trame.widgets import html, vuetify3 as v3 + +from quickview import __version__ as quickview_version +from quickview.components import css, tools +from quickview.utils import js, constants + + +class Tools(v3.VNavigationDrawer): + def __init__(self, reset_camera=None): + super().__init__( + permanent=True, + rail=("compact_drawer", True), + width=220, + style="transform: none;", + ) + + with self: + with html.Div(style=css.NAV_BAR_TOP): + with v3.VList( + density="compact", + nav=True, + select_strategy="independent", + v_model_selected=("active_tools", ["load-data"]), + ): + tools.AppLogo() + tools.OpenFile() + tools.FieldSelection() + tools.MapProjection() + tools.ResetCamera(click=reset_camera) + + v3.VDivider(classes="my-1") # --------------------- + + tools.LayoutManagement() + tools.Cropping() + tools.DataSelection() + tools.Animation() + + v3.VDivider(classes="my-1") # --------------------- + + tools.StateImportExport() + + # dev add-on ui reload + if self.server.hot_reload: + tools.ActionButton( + compact="compact_drawer", + title="Refresh UI", + icon="mdi-database-refresh-outline", + click=self.ctrl.on_server_reload, + ) + + with html.Div(style=css.NAV_BAR_BOTTOM): + v3.VDivider() + v3.VLabel( + f"{quickview_version}", + classes="text-center text-caption d-block text-wrap", + ) + + +class FieldSelection(v3.VNavigationDrawer): + def __init__(self, load_variables=None): + super().__init__( + model_value=(js.is_active("select-fields"),), + width=500, + permanent=True, + style=(f"{js.is_active('select-fields')} ? 'transform: none;' : ''",), + ) + + with self: + with html.Div(style="position:fixed;top:0;width: 500px;"): + with v3.VCardActions(key="variables_selected.length"): + for name, color in [ + ("surfaces", "success"), + ("interfaces", "info"), + ("midpoints", "warning"), + ]: + v3.VChip( + js.var_title(name), + color=color, + v_show=js.var_count(name), + size="small", + closable=True, + click_close=js.var_remove(name), + ) + + v3.VSpacer() + v3.VBtn( + classes="text-none", + color="primary", + prepend_icon="mdi-database", + text=( + "`Load ${variables_selected.length} variable${variables_selected.length > 1 ? 's' :''}`", + ), + variant="flat", + disabled=( + "variables_selected.length === 0 || variables_loaded", + ), + click=load_variables, + ) + + v3.VTextField( + v_model=("variables_filter", ""), + hide_details=True, + color="primary", + placeholder="Filter", + density="compact", + variant="outlined", + classes="mx-2", + prepend_inner_icon="mdi-magnify", + clearable=True, + ) + with html.Div(style="margin:1px;"): + v3.VDataTable( + v_model=("variables_selected", []), + show_select=True, + item_value="id", + density="compact", + fixed_header=True, + headers=( + "variables_headers", + constants.VAR_HEADERS, + ), + items=("variables_listing", []), + height="calc(100vh - 6rem)", + style="user-select: none; cursor: pointer;", + hover=True, + search=("variables_filter", ""), + items_per_page=-1, + hide_default_footer=True, + ) + + @change("variables_selected") + def _on_dirty_variable_selection(self, **_): + self.state.variables_loaded = False diff --git a/quickview/components/file_browser.py b/quickview/components/file_browser.py new file mode 100644 index 0000000..fac717a --- /dev/null +++ b/quickview/components/file_browser.py @@ -0,0 +1,444 @@ +import json +import re +from pathlib import Path +from paraview import simple +from trame.widgets import vuetify3 as v3, html +from trame.app import TrameComponent + +DIRECTORY = dict(icon="mdi-folder", type="directory") +GROUP = dict(icon="mdi-file-document-multiple-outline", type="group") +FILE = dict(icon="mdi-file-document-outline", type="file") + +HEADERS = [ + {"title": "Name", "align": "start", "key": "name", "sortable": False}, + {"title": "Size", "align": "end", "key": "size", "sortable": False}, + {"title": "Date", "align": "end", "key": "modified", "sortable": False}, +] + + +def sort_by_name(e): + return e.get("name") + + +def to_type(e): + return e.get("type", "") + + +def to_suffix(e): + return Path(e.get("name", "")).suffix + + +class ParaViewFileBrowser(TrameComponent): + def __init__( + self, + server, + prefix="pv_files", + home=None, + current=None, + exclude=r"^\.|~$|^\$", + group=r"[0-9]+\.", + ): + super().__init__(server) + self._prefix = prefix + + self._enable_groups = True + self._home_path = Path(home).resolve() if home else Path.home() + self._current_path = Path(current).resolve() if current else self._home_path + self.pattern_exclude = re.compile(exclude) + self.pattern_group = re.compile(group) + + # Disable state import by default + self.set("is_state_file", False) + + self._pxm = simple.servermanager.ProxyManager() + self._proxy_listing = self._pxm.NewProxy("misc", "ListDirectory") + self._proxy_directories = simple.servermanager.VectorProperty( + self._proxy_listing, self._proxy_listing.GetProperty("DirectoryList") + ) + self._proxy_files = simple.servermanager.VectorProperty( + self._proxy_listing, self._proxy_listing.GetProperty("FileList") + ) + + # Initialize trame state + self.update_listing() + + def name(self, name): + return f"{self._prefix}_{name}" + + def set(self, name, value): + self.state[self.name(name)] = value + + def get(self, name): + return self.state[self.name(name)] + + def update_listing(self, selection=None): + with self.state: + self.set("active", -1) + self.set("listing", self.listing) + self.set("selected", selection) + + @property + def enable_groups(self): + return self._enable_groups + + @enable_groups.setter + def enable_groups(self, v): + self._enable_groups = v + + @property + def listing(self): + directories = [] + files = [] + groups = [] + g_map = {} + + self._proxy_listing.List(str(self._current_path.resolve())) + self._proxy_listing.UpdatePropertyInformation() + + # Files + Groups + file_listing = [] + if len(self._proxy_files) > 1: + file_listing = self._proxy_files.GetData() + if len(self._proxy_files) == 1: + file_listing.append(self._proxy_files.GetData()) + file_listing = [ + file_name + for file_name in file_listing + if not re.search(self.pattern_exclude, file_name) + ] + for file_name in file_listing: + f = self._current_path / file_name + stats = f.stat() + + # Group or file? + file_split = re.split(self.pattern_group, file_name) + if self.enable_groups and len(file_split) == 2: + # Group + g_name = "*.".join(file_split) + if g_name not in g_map: + g_entry = dict( + name=g_name, + modified=stats.st_mtime, + size=0, + files=[], + **GROUP, + ) + g_map[g_name] = g_entry + groups.append(g_entry) + + g_map[g_name]["size"] += stats.st_size + g_map[g_name]["files"].append(file_name) + # Many need to sort files??? + else: + # File + files.append( + dict( + name=f.name, + modified=stats.st_mtime, + size=stats.st_size, + **FILE, + ) + ) + + # Directories + dir_listing = [] + if len(self._proxy_directories) > 1: + dir_listing = self._proxy_directories.GetData() + if len(self._proxy_directories) == 1: + dir_listing.append(self._proxy_directories.GetData()) + dir_listing = [ + dir_name + for dir_name in dir_listing + if not re.search(self.pattern_exclude, dir_name) + ] + for dir_name in dir_listing: + f = self._current_path / dir_name + directories.append( + dict(name=f.name, modified=f.stat().st_mtime, **DIRECTORY) + ) + + # Sort content + directories.sort(key=sort_by_name) + groups.sort(key=sort_by_name) + files.sort(key=sort_by_name) + + return [ + {**e, "index": i} for i, e in enumerate([*directories, *groups, *files]) + ] + + def open_entry(self, entry): + entry_type = entry.get("type") + if entry_type == "directory": + self._current_path = self._current_path / entry.get("name") + self.update_listing() + return entry_type, str(self._current_path) + if entry_type == "group": + files = entry.get("files", []) + self.update_listing() + return entry, [str(self._current_path / f) for f in files] + if entry_type == "file": + file = self._current_path / entry.get("name") + file_name = file.name.lower() + full_path = str(file) + var_name = ( + "data_connectivity" + if "connectivity_" in file_name + else "data_simulation" + ) + self.set(var_name, full_path) + self.update_listing(full_path) + return entry_type, full_path + + return None + + @property + def active_path(self): + entry = self.get("listing")[self.get("active")] + return str(self._current_path / entry.get("name")) + + def set_data_connectivity(self, value=None): + self.set("data_connectivity", value or self.active_path) + + def set_data_simulation(self, value=None): + self.set("data_simulation", value or self.active_path) + + def goto_home(self): + self._current_path = self._home_path + self.update_listing() + + def goto_parent(self): + self._current_path = self._current_path.parent + self.update_listing() + + def open_dataset(self, entry): + event = {} + if to_type(entry) == "group": + files = [str(self._current_path / f) for f in entry.get("files")] + source = simple.OpenDataFile(files) + representation = simple.Show(source) + view = simple.Render() + event = dict( + source=source, representation=representation, view=view, type="group" + ) + else: + source = simple.OpenDataFile(str(self._current_path / entry.get("name"))) + representation = simple.Show(source) + view = simple.Render() + event = dict( + source=source, representation=representation, view=view, type="dataset" + ) + + return event + + def select_entry(self, entry): + with self.state as state: + state[f"{self._prefix}_active"] = entry.get("index", 0) if entry else -1 + file_path = Path(self.active_path) + + # Check if it is a state file + if file_path.suffix == ".json" and file_path.exists(): + state_content = json.loads(file_path.read_text()) + self.set( + "is_state_file", + all( + ( + k in state_content + for k in [ + "files", + "variables-selection", + "layout", + "data-selection", + "views", + ] + ) + ), + ) + else: + self.set("is_state_file", False) + + def load_data_files(self, **_): + self.set("loading", True) + print("Load files:") + print(" - simulation:", self.get("data_simulation")) + print(" - connectivity:", self.get("data_connectivity")) + self.ctrl.file_selection_load( + self.get("data_simulation"), self.get("data_connectivity") + ) + + def import_state_file(self): + self.set("state_loading", True) + + state_content = json.loads(Path(self.active_path).read_text()) + self.ctrl.import_state(state_content) + + def cancel(self): + self.ctrl.file_selection_cancel() + + def loading_completed(self, valid): + with self.state: + self.set("loading", False) + self.set("error", not valid) + + def ui(self): + with v3.VCard(rounded="lg"): + with v3.VCardTitle("File loading", classes="d-flex align-center px-3"): + v3.VSpacer() + v3.VBtn( + icon="mdi-home", + variant="flat", + size="small", + click=self.goto_home, + ) + v3.VBtn( + icon="mdi-folder-upload-outline", + variant="flat", + size="small", + click=self.goto_parent, + ) + v3.VTextField( + v_model=self.name("filter"), + hide_details=True, + color="primary", + placeholder="filter", + density="compact", + variant="outlined", + classes="ml-2", + prepend_inner_icon="mdi-magnify", + clearable=True, + ) + + with v3.VCardText( + classes="rounded-lg border border-opacity-25 pa-0 mx-3 my-0 overflow-hidden" + ): + style_align_center = "d-flex align-center " + with v3.VDataTable( + density="compact", + fixed_header=True, + headers=(self.name("headers"), HEADERS), + items=(self.name("listing"), []), + height="calc(80vh - 20rem)", + style="user-select: none; cursor: pointer;", + hover=True, + search=(self.name("filter"), ""), + items_per_page=-1, + ): + v3.Template(raw_attrs=["v-slot:bottom"]) + with v3.Template(raw_attrs=['v-slot:item="{ index, item }"']): + with v3.VDataTableRow( + index=("index",), + item=("item",), + click=(self.select_entry, "[item]"), + dblclick=(self.open_entry, "[item]"), + classes=( + f"{{ 'bg-grey': item.index === {self.name('active')}, 'cursor-pointer': 1 }}", + ), + ): + with v3.Template(raw_attrs=["v-slot:item.name"]): + with html.Div(classes=style_align_center): + v3.VIcon( + "{{ item.icon }}", + size="small", + classes="mr-2", + ) + html.Div("{{ item.name }}") + + with v3.Template(raw_attrs=["v-slot:item.size"]): + with html.Div( + classes=style_align_center + " justify-end", + ): + html.Div( + "{{ utils.fmt.bytes(item.size, 0) }}", + v_if="item.size", + ) + html.Div(" - ", v_else=True) + + with v3.Template(raw_attrs=["v-slot:item.modified"]): + with html.Div( + classes=style_align_center + " justify-end", + ): + html.Div( + "{{ new Date(item.modified * 1000).toDateString() }}" + ) + + with v3.VCol(): + html.Label( + "Simulation File", + classes="text-subtitle-1 font-weight-medium d-block", + ) + v3.VTextField( + v_model=(self.name("data_simulation"), ""), + density="compact", + variant="outlined", + disabled=True, + messages="EAM's history output on the physics grids (pg2 grids) written by EAMv2, v3, and an intermediate version towards v4 (EAMxx).", + ) + html.Label( + "Connectivity File", + classes="text-subtitle-1 font-weight-medium d-block", + ) + v3.VTextField( + v_model=(self.name("data_connectivity"), ""), + density="compact", + variant="outlined", + disabled=True, + messages="The horizontal grids used by EAM are cubed spheres. Since these are unstructed grids, QuickView needs to know how to map data to the globe. Therefore, for each simulation data file, a 'connectivity file' needs to be provided.", + ) + + v3.VDivider() + with v3.VCardActions(classes="pa-3"): + v3.VBtn( + classes="text-none", + variant="tonal", + text="Simulation", + prepend_icon="mdi-database-plus", + disabled=( + f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", + ), + click=self.set_data_simulation, + ) + v3.VBtn( + classes="text-none", + text="Connectivity", + variant="tonal", + prepend_icon="mdi-vector-polyline-plus", + disabled=( + f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", + ), + click=self.set_data_connectivity, + ) + v3.VBtn( + classes="text-none", + text="Reset", + variant="tonal", + prepend_icon="mdi-close-octagon-outline", + click=f"{self.name('data_connectivity')}='';{self.name('data_simulation')}='';{self.name('error')}=false", + ) + v3.VSpacer() + v3.VBtn( + border=True, + classes="text-none", + color="surface", + text="Cancel", + variant="flat", + click=self.cancel, + ) + v3.VBtn( + disabled=(f"!{self.name('is_state_file')}",), + loading=(self.name("state_loading"), False), + classes="text-none", + color="primary", + text="Import state file", + variant="flat", + click=self.import_state_file, + ) + v3.VBtn( + classes="text-none", + color=(f"{self.name('error')} ? 'error' : 'primary'",), + text="Load files", + variant="flat", + disabled=( + f"!{self.name('data_simulation')} || !{self.name('data_connectivity')} || {self.name('error')}", + ), + loading=(self.name("loading"), False), + click=self.load_data_files, + ) diff --git a/quickview/components/toolbars.py b/quickview/components/toolbars.py new file mode 100644 index 0000000..c596bc8 --- /dev/null +++ b/quickview/components/toolbars.py @@ -0,0 +1,327 @@ +import asyncio + +from trame.app import asynchronous +from trame.decorators import change +from trame.widgets import vuetify3 as v3 + +from quickview.utils import js, constants + +DENSITY = { + "adjust-layout": "compact", + "adjust-databounds": "default", + "select-slice-time": "default", + "animation-controls": "compact", +} + +VALUES = list(DENSITY.keys()) + +DEFAULT_STYLES = { + "color": "white", + "classes": "border-b-thin", +} + + +def to_kwargs(value): + return { + "v_show": js.is_active(value), + "density": DENSITY[value], + **DEFAULT_STYLES, + } + + +class Layout(v3.VToolbar): + def __init__(self, apply_size=None): + super().__init__(**to_kwargs("adjust-layout")) + + with self: + v3.VIcon("mdi-collage", classes="px-6 opacity-50") + v3.VLabel("Layout Controls", classes="text-subtitle-2") + v3.VSpacer() + + v3.VSlider( + v_model=("aspect_ratio", 2), + prepend_icon="mdi-aspect-ratio", + min=1, + max=2, + step=0.1, + density="compact", + hide_details=True, + style="max-width: 400px;", + ) + v3.VSpacer() + v3.VCheckbox( + v_model=("layout_grouped", True), + label=("layout_grouped ? 'Grouped' : 'Uniform'",), + hide_details=True, + inset=True, + false_icon="mdi-apps", + true_icon="mdi-focus-field", + density="compact", + ) + + with v3.VBtn( + "Size", + classes="text-none mx-4", + prepend_icon="mdi-view-module", + append_icon="mdi-menu-down", + ): + with v3.VMenu(activator="parent"): + with v3.VList(density="compact"): + v3.VListItem( + title="Auto", + click=( + apply_size, + "[0]", + ), + ) + v3.VListItem( + title="Full Width", + click=( + apply_size, + "[1]", + ), + ) + v3.VListItem( + title="2 Columns", + click=( + apply_size, + "[2]", + ), + ) + v3.VListItem( + title="3 Columns", + click=( + apply_size, + "[3]", + ), + ) + v3.VListItem( + title="4 Columns", + click=( + apply_size, + "[4]", + ), + ) + v3.VListItem( + title="6 Columns", + click=( + apply_size, + "[6]", + ), + ) + + +class Cropping(v3.VToolbar): + def __init__(self): + super().__init__(**to_kwargs("adjust-databounds")) + + with self: + v3.VIcon("mdi-crop", classes="pl-6 opacity-50") + with v3.VRow(classes="ma-0 px-2 align-center"): + with v3.VCol(cols=6): + with v3.VRow(classes="mx-2 my-0"): + v3.VLabel( + "Longitude", + classes="text-subtitle-2", + ) + v3.VSpacer() + v3.VLabel( + "{{ crop_longitude }}", + classes="text-body-2", + ) + v3.VRangeSlider( + v_model=("crop_longitude", [-180, 180]), + min=-180, + max=180, + step=1, + density="compact", + hide_details=True, + ) + with v3.VCol(cols=6): + with v3.VRow(classes="mx-2 my-0"): + v3.VLabel( + "Latitude", + classes="text-subtitle-2", + ) + v3.VSpacer() + v3.VLabel( + "{{ crop_latitude }}", + classes="text-body-2", + ) + v3.VRangeSlider( + v_model=("crop_latitude", [-90, 90]), + min=-90, + max=90, + step=1, + density="compact", + hide_details=True, + ) + + +class DataSelection(v3.VToolbar): + def __init__(self): + super().__init__(**to_kwargs("select-slice-time")) + + with self: + v3.VIcon("mdi-tune-variant", classes="ml-3 opacity-50") + with v3.VRow(classes="ma-0 pr-2 align-center", dense=True): + # midpoint layer + with v3.VCol( + cols=("toolbar_slider_cols", 4), + v_show="midpoints.length > 1", + ): + with v3.VRow(classes="mx-2 my-0"): + v3.VLabel( + "Layer Midpoints", + classes="text-subtitle-2", + ) + v3.VSpacer() + v3.VLabel( + "{{ parseFloat(midpoints[midpoint_idx] || 0).toFixed(2) }} hPa (k={{ midpoint_idx }})", + classes="text-body-2", + ) + v3.VSlider( + v_model=("midpoint_idx", 0), + min=0, + max=("Math.max(0, midpoints.length - 1)",), + step=1, + density="compact", + hide_details=True, + ) + + # interface layer + with v3.VCol( + cols=("toolbar_slider_cols", 4), + v_show="interfaces.length > 1", + ): + with v3.VRow(classes="mx-2 my-0"): + v3.VLabel( + "Layer Interfaces", + classes="text-subtitle-2", + ) + v3.VSpacer() + v3.VLabel( + "{{ parseFloat(interfaces[interface_idx] || 0).toFixed(2) }} hPa (k={{interface_idx}})", + classes="text-body-2", + ) + v3.VSlider( + v_model=("interface_idx", 0), + min=0, + max=("Math.max(0, interfaces.length - 1)",), + step=1, + density="compact", + hide_details=True, + ) + + # time + with v3.VCol( + cols=("toolbar_slider_cols", 4), + v_show="timestamps.length > 1", + ): + self.state.setdefault("time_value", 80.50) + with v3.VRow(classes="mx-2 my-0"): + v3.VLabel("Time", classes="text-subtitle-2") + v3.VSpacer() + v3.VLabel( + "{{ parseFloat(timestamps[time_idx]).toFixed(2) }} (t={{time_idx}})", + classes="text-body-2", + ) + v3.VSlider( + v_model=("time_idx", 0), + min=0, + max=("Math.max(0, timestamps.length - 1)",), + step=1, + density="compact", + hide_details=True, + ) + + +class Animation(v3.VToolbar): + def __init__(self): + super().__init__(**to_kwargs("animation-controls")) + + with self: + v3.VIcon( + "mdi-movie-open-cog-outline", + classes="px-6 opacity-50", + ) + with v3.VRow(classes="ma-0 px-2 align-center"): + v3.VSelect( + v_model=("animation_track", "timestamps"), + items=("animation_tracks", []), + flat=True, + variant="plain", + hide_details=True, + density="compact", + style="max-width: 10rem;", + ) + v3.VDivider(vertical=True, classes="mx-2") + v3.VSlider( + v_model=("animation_step", 1), + min=0, + max=("amimation_step_max", 0), + step=1, + hide_details=True, + density="compact", + classes="mx-4", + ) + v3.VDivider(vertical=True, classes="mx-2") + v3.VIconBtn( + icon="mdi-page-first", + flat=True, + disabled=("animation_step === 0",), + click="animation_step = 0", + ) + v3.VIconBtn( + icon="mdi-chevron-left", + flat=True, + disabled=("animation_step === 0",), + click="animation_step = Math.max(0, animation_step - 1)", + ) + v3.VIconBtn( + icon="mdi-chevron-right", + flat=True, + disabled=("animation_step === amimation_step_max",), + click="animation_step = Math.min(amimation_step_max, animation_step + 1)", + ) + v3.VIconBtn( + icon="mdi-page-last", + disabled=("animation_step === amimation_step_max",), + flat=True, + click="animation_step = amimation_step_max", + ) + v3.VDivider(vertical=True, classes="mx-2") + v3.VIconBtn( + icon=("animation_play ? 'mdi-stop' : 'mdi-play'",), + flat=True, + click="animation_play = !animation_play", + ) + + @change("animation_track") + def _on_animation_track_change(self, animation_track, **_): + self.state.animation_step = 0 + self.state.amimation_step_max = 0 + + if animation_track: + self.state.amimation_step_max = len(self.state[animation_track]) - 1 + + @change("animation_step") + def _on_animation_step(self, animation_track, animation_step, **_): + if animation_track: + self.state[constants.TRACK_STEPS[animation_track]] = animation_step + + @change("animation_play") + def _on_animation_play(self, animation_play, **_): + if animation_play: + asynchronous.create_task(self._run_animation()) + + async def _run_animation(self): + with self.state as s: + while s.animation_play: + await asyncio.sleep(0.1) + if s.animation_step < s.amimation_step_max: + with s: + s.animation_step += 1 + await self.server.network_completion + else: + s.animation_play = False diff --git a/quickview/components/tools.py b/quickview/components/tools.py new file mode 100644 index 0000000..983c28e --- /dev/null +++ b/quickview/components/tools.py @@ -0,0 +1,253 @@ +from trame.widgets import vuetify3 as v3 + +from quickview import __version__ as quickview_version +from quickview.assets import ASSETS + + +# ----------------------------------------------------------------------------- +# Logo / Help +# ----------------------------------------------------------------------------- +class AppLogo(v3.VTooltip): + def __init__(self, compact="compact_drawer"): + super().__init__( + text=f"QuickView {quickview_version}", + disabled=(f"!{compact}",), + ) + with self: + with v3.Template(v_slot_activator="{ props }"): + with v3.VListItem( + v_bind="props", + title=(f"{compact} ? null : 'QuickView {quickview_version}'",), + classes="text-h6", + click=f"{compact} = !{compact}", + ): + with v3.Template(raw_attrs=["#prepend"]): + v3.VAvatar( + image=ASSETS.icon, + size=24, + classes="me-4", + ) + v3.VProgressCircular( + color="primary", + indeterminate=True, + v_show="trame__busy", + v_if=compact, + style="position: absolute !important;left: 50%;top: 50%; transform: translate(-50%, -50%);", + ) + v3.VProgressLinear( + v_else=True, + color="primary", + indeterminate=True, + v_show="trame__busy", + absolute=True, + style="top:90%;width:100%;", + ) + + +# ----------------------------------------------------------------------------- +# Clickable tools +# ----------------------------------------------------------------------------- +class ActionButton(v3.VTooltip): + def __init__(self, compact, title, icon, click): + super().__init__(text=title, disabled=(f"!{compact}",)) + with self: + with v3.Template(v_slot_activator="{ props }"): + v3.VListItem( + v_bind="props", + prepend_icon=icon, + title=(f"{compact} ? null : '{title}'",), + click=click, + ) + + +class ResetCamera(ActionButton): + def __init__(self, compact="compact_drawer", click=None): + super().__init__( + compact=compact, + title="Reset camera", + icon="mdi-crop-free", + click=click, + ) + + +class ToggleHelp(ActionButton): + def __init__(self, compact="compact_drawer"): + super().__init__( + compact=compact, + title="Toggle Help", + icon="mdi-lifebuoy", + click=f"{compact} = !{compact}", + ) + + +# ----------------------------------------------------------------------------- +# Toggle toolbar tools +# ----------------------------------------------------------------------------- +class ToggleButton(v3.VTooltip): + def __init__(self, compact, title, icon, value, disabled=None): + super().__init__(text=title, disabled=(f"!{compact}",)) + + add_on = {} + if disabled: + add_on["disabled"] = (disabled,) + + with self: + with v3.Template(v_slot_activator="{ props }"): + v3.VListItem( + v_bind="props", + prepend_icon=icon, + value=value, + title=(f"{compact} ? null : '{title}'",), + **add_on, + ) + + +class LayoutManagement(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="Layout management", + icon="mdi-collage", + value="adjust-layout", + ) + + +class OpenFile(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="File loading", + icon="mdi-file-document-outline", + value="load-data", + ) + + +class FieldSelection(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="Fields selection", + icon="mdi-list-status", + value="select-fields", + disabled="variables_listing.length === 0", + ) + + +class Cropping(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="Lat/Long cropping", + icon="mdi-crop", + value="adjust-databounds", + ) + + +class DataSelection(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="Slice selection", + icon="mdi-tune-variant", + value="select-slice-time", + ) + + +class Animation(ToggleButton): + def __init__(self): + super().__init__( + compact="compact_drawer", + title="Animation controls", + icon="mdi-movie-open-cog-outline", + value="animation-controls", + ) + + +# ----------------------------------------------------------------------------- +# Menu tools +# ----------------------------------------------------------------------------- +class MapProjection(v3.VTooltip): + def __init__(self, compact="compact_drawer", title="Map Projection"): + super().__init__( + text=title, + disabled=(f"!{compact}",), + ) + with self: + with v3.Template(v_slot_activator="{ props }"): + with v3.VListItem( + v_bind="props", + prepend_icon="mdi-earth", + title=(f"{compact} ? null : '{title}'",), + ): + with v3.VMenu( + activator="parent", + location="end", + offset=10, + ): + v3.VList( + mandatory=True, + v_model_selected=( + "projection", + ["Cyl. Equidistant"], + ), + density="compact", + items=("projections", self.options), + ) + + @property + def options(self): + return [ + { + "title": "Cylindrical Equidistant", + "value": "Cyl. Equidistant", + }, + { + "title": "Robinson", + "value": "Robinson", + }, + { + "title": "Mollweide", + "value": "Mollweide", + }, + ] + + +class StateImportExport(v3.VTooltip): + def __init__(self, compact="compact_drawer", title="State Import/Export"): + super().__init__( + text=title, + disabled=(f"!{compact}",), + ) + with self: + with v3.Template(v_slot_activator="{ props }"): + with v3.VListItem( + v_bind="props", + prepend_icon="mdi-folder-arrow-left-right-outline", + title=(f"{compact} ? null : '{title}'",), + ): + with v3.VMenu( + activator="parent", + location="end", + offset=10, + ): + with v3.VList(density="compact"): + v3.VListItem( + title="Download state file", + prepend_icon="mdi-file-download-outline", + click="show_export_dialog=true", + disabled=("!variables_loaded",), + ) + v3.VListItem( + title="Upload state file", + prepend_icon="mdi-file-upload-outline", + click="utils.get('document').querySelector('#fileUpload').click()", + ) + + v3.VFileInput( + id="fileUpload", + v_show=False, + v_model=("upload_state_file", None), + density="compact", + prepend_icon=False, + style="position: absolute;left:-1000px;width:1px;", + ) diff --git a/quickview/interface.py b/quickview/interface.py index 76005c0..a22f362 100644 --- a/quickview/interface.py +++ b/quickview/interface.py @@ -247,14 +247,6 @@ def __init__( else: self.update_state_from_config(initstate) - @property - def state(self): - return self.server.state - - @property - def ctrl(self): - return self.server.controller - @life_cycle.server_ready def _tauri_ready(self, **_): os.write(1, f"tauri-server-port={self.server.port}\n".encode()) @@ -476,6 +468,10 @@ def load_variables(self, use_cached_layout=False): 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 diff --git a/quickview/module/__init__.py b/quickview/module/__init__.py new file mode 100644 index 0000000..84b3d26 --- /dev/null +++ b/quickview/module/__init__.py @@ -0,0 +1,6 @@ +from pathlib import Path + +__all__ = ["serve", "scripts"] + +serve = {"quick_view": str(Path(__file__).with_name("serve").resolve())} +scripts = ["quick_view/utils.js"] diff --git a/quickview/module/serve/utils.js b/quickview/module/serve/utils.js new file mode 100644 index 0000000..0371e77 --- /dev/null +++ b/quickview/module/serve/utils.js @@ -0,0 +1,11 @@ +window.trame.utils.quickview = { + formatRange(value, useLog) { + if (value === null || value === undefined || isNaN(value)) { + return 'Auto'; + } + if (useLog && value > 0) { + return `10^(${Math.log10(value).toFixed(1)})`; + } + return value.toExponential(1); + } +} diff --git a/quickview/presets/__init__.py b/quickview/presets/__init__.py new file mode 100644 index 0000000..26b7667 --- /dev/null +++ b/quickview/presets/__init__.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from paraview import simple + +ALL_PRESETS = set(simple.GetLookupTableNames()) +CUSTOM_PRESETS = set() + +# Import any missing preset +for preset_file in Path(__file__).parent.glob("*_PARAVIEW.xml"): + preset_name = preset_file.name[:-13] # remove _PARAVIEW.xml + if preset_name not in ALL_PRESETS: + try: + simple.ImportPresets(str(preset_file.resolve())) + ALL_PRESETS.add(preset_name) + CUSTOM_PRESETS.add(preset_name) + except Exception as e: + print("Error importing color preset to ParaView", e) + +PARAVIEW_PRESETS = ALL_PRESETS - CUSTOM_PRESETS diff --git a/quickview/utils/cli.py b/quickview/utils/cli.py new file mode 100644 index 0000000..5c50314 --- /dev/null +++ b/quickview/utils/cli.py @@ -0,0 +1,29 @@ +from pathlib import Path + + +def configure_and_parse(parser): + 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", + default=str(Path.cwd().resolve()), + help="working directory (to store session data)", + ) + + return parser.parse_known_args()[0] diff --git a/quickview/utils/compute.py b/quickview/utils/compute.py new file mode 100644 index 0000000..ca2beeb --- /dev/null +++ b/quickview/utils/compute.py @@ -0,0 +1,45 @@ +from paraview import servermanager +import numpy as np +from typing import Optional + + +def calculate_weighted_average( + data_array: np.ndarray, weights: Optional[np.ndarray] = None +) -> float: + """ + Calculate average of data, optionally weighted. + + Args: + data_array: The data to average + weights: Optional weights for weighted averaging (e.g., area weights) + + Returns: + The (weighted) average, handling NaN values + """ + data = np.array(data_array) + weights = np.array(weights) + # Handle NaN values + if np.isnan(data).any(): + mask = ~np.isnan(data) + if not np.any(mask): + return np.nan # all values are NaN + data = data[mask] + if weights is not None: + weights = weights[mask] + + if weights is not None: + return float(np.average(data, weights=weights)) + else: + return float(np.mean(data)) + + +def extract_avgs(pv_data, array_names): + results = {} + vtk_data = servermanager.Fetch(pv_data) + area_array = vtk_data.GetCellData().GetArray("area") + for name in array_names: + vtk_array = vtk_data.GetCellData().GetArray(name) + avg_value = calculate_weighted_average(vtk_array, area_array) + results[name] = avg_value + + return results diff --git a/quickview/utils/constants.py b/quickview/utils/constants.py new file mode 100644 index 0000000..cf43c8f --- /dev/null +++ b/quickview/utils/constants.py @@ -0,0 +1,16 @@ +VAR_HEADERS = [ + {"title": "Name", "align": "start", "key": "name", "sortable": True}, + {"title": "Type", "align": "start", "key": "type", "sortable": True}, +] + +TRACK_STEPS = { + "timestamps": "time_idx", + "interfaces": "interface_idx", + "midpoints": "midpoint_idx", +} + +TRACK_ENTRIES = { + "timestamps": {"title": "Time", "value": "timestamps"}, + "midpoints": {"title": "Layer Midpoints", "value": "midpoints"}, + "interfaces": {"title": "Layer Interfaces", "value": "interfaces"}, +} diff --git a/quickview/utils/js.py b/quickview/utils/js.py new file mode 100644 index 0000000..441936e --- /dev/null +++ b/quickview/utils/js.py @@ -0,0 +1,16 @@ +def var_count(name): + return f"variables_selected.filter((v) => v[0] === '{name[0]}').length" + + +def var_remove(name): + return ( + f"variables_selected = variables_selected.filter((v) => v[0] !== '{name[0]}')" + ) + + +def var_title(name): + return " ".join(["{{", var_count(name), "}}", name.capitalize()]) + + +def is_active(name): + return f"active_tools.includes('{name}')" diff --git a/quickview/view_manager2.py b/quickview/view_manager2.py new file mode 100644 index 0000000..579de79 --- /dev/null +++ b/quickview/view_manager2.py @@ -0,0 +1,584 @@ +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 hot_reload, controller + +from trame_dataclass.core import StateDataModel + +from paraview import simple + +from quickview.utils.color import get_cached_colorbar_image +from quickview.utils.color import COLORBAR_CACHE + + +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, +} + +TYPE_COLOR = { + "s": "success", + "i": "info", + "m": "warning", +} + + +class ViewConfiguration(StateDataModel): + variable: str + preset: str = "Inferno (matplotlib)" + preset_img: str + invert: bool = False + use_log_scale: bool = False + color_range: list[float] = (0, 1) + override_range: bool = False + order: int = 0 + size: int = 4 + menu: bool = False + swap_group: list[str] + + +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.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( + ["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 update_color_range(self, *_): + if self.config.override_range: + if math.isnan(self.config.color_range[0]) or math.isnan( + self.config.color_range[1] + ): + 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.lut.RescaleTransferFunction(*data_range) + self.render() + + @hot_reload + 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", + classes=( + "active_layout !== 'auto_layout' ? 'h-100' : '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", + ): + with v3.VBtn( + icon=True, + density="compact", + variant="plain", + classes="mx-1", + size="small", + ): + v3.VIcon( + "mdi-arrow-expand", + size="x-small", + style="transform: scale(-1, 1);", + ) + with v3.VMenu(activator="parent"): + with self.config.provide_as("config"): + with v3.VList(density="compact"): + v3.VListItem( + subtitle="Full Screen", + click=f"active_layout = '{self.name}'", + ) + v3.VDivider() + + v3.VListItem( + subtitle="Full width", + click="active_layout = 'auto_layout';config.size = 12", + ) + v3.VListItem( + subtitle="1/2 width", + click="active_layout = 'auto_layout';config.size = 6", + ) + v3.VListItem( + subtitle="1/3 width", + click="active_layout = 'auto_layout';config.size = 4", + ) + v3.VListItem( + subtitle="1/4 width", + click="active_layout = 'auto_layout';config.size = 3", + ) + v3.VListItem( + subtitle="1/6 width", + click="active_layout = 'auto_layout';config.size = 2", + ) + 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.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, + } + """, + ), + ): + pvw.VtkRemoteView( + self.view, interactive_ratio=1, ctx_name=self.name + ) + + with self.config.provide_as("config"): + with html.Div( + classes="bg-blue-grey-darken-2 d-flex align-center", + style="height:1rem;position:relative;top:0;user-select:none;cursor:context-menu;", + ): + with v3.VMenu( + v_model="config.menu", + activator="parent", + location=( + "active_layout !== 'auto_layout' || config.size == 12 ? 'top' : 'end'", + ), + close_on_content_click=False, + ): + with v3.VCard(style="max-width: 360px;"): + with v3.VCardItem(classes="pb-0"): + v3.VIconBtn( + icon=( + "config.invert ? 'mdi-invert-colors' : 'mdi-invert-colors-off'", + ), + click="config.invert = !config.invert", + size="small", + text="Invert", + variant="text", + ) + v3.VIconBtn( + icon=( + "config.use_log_scale ? 'mdi-math-log' : 'mdi-stairs'", + ), + click="config.use_log_scale = !config.use_log_scale", + size="small", + text=( + "config.use_log_scale ? 'Log scale' : 'Linear scale'", + ), + variant="text", + ) + v3.VIconBtn( + icon=( + "config.override_range ? 'mdi-arrow-expand-horizontal' : 'mdi-pencil'", + ), + click="config.override_range = !config.override_range", + size="small", + text="Use data range", + variant="text", + ) + + with v3.Template(v_slot_append=True): + v3.VLabel( + "{{ config.preset }}", + classes="mr-2 text-caption", + ) + v3.VIconBtn( + icon="mdi-close", + size="small", + text="Close", + click="config.menu=false", + ) + with v3.VCardItem( + v_show="config.override_range", classes="py-0" + ): + v3.VNumberInput( + model_value=("config.color_range[0]",), + update_modelValue="config.color_range = [Number($event), config.color_range[1]]", + hide_details=True, + density="compact", + variant="outlined", + flat=True, + label="Min", + classes="mt-2", + control_variant="hidden", + precision=("15",), + step=( + "Math.max(0.0001, (config.color_range[1] - config.color_range[0]) / 255)", + ), + ) + v3.VNumberInput( + model_value=("config.color_range[1]",), + update_modelValue="config.color_range = [config.color_range[0], Number($event)]", + hide_details=True, + density="compact", + variant="outlined", + flat=True, + label="Max", + classes="mt-2", + control_variant="hidden", + precision=("15",), + step=( + "Math.max(0.0001, (config.color_range[1] - config.color_range[0]) / 255)", + ), + ) + v3.VDivider(classes="mt-2") + with v3.VList(density="compact", max_height="40vh"): + with v3.VListItem( + v_for="url, name in (config.invert ? luts_inverted : luts_normal)", + key="name", + subtitle=("name",), + click=( + self.update_color_preset, + "[name, config.invert, config.use_log_scale]", + ), + active=("config.preset === name",), + ): + html.Img( + src=("url",), + style="width:100%;min-width:20rem;height:1rem;", + classes="rounded", + ) + html.Div( + "{{ utils.quickview.formatRange(config.color_range?.[0], config.use_log_scale) }}", + classes="text-caption px-2 text-no-wrap", + ) + with html.Div( + classes="overflow-hidden rounded", style="height:70%;" + ): + html.Img( + src=("config.preset_img",), + style="width:100%;height:2rem;", + draggable=False, + ) + html.Div( + "{{ utils.quickview.formatRange(config.color_range?.[1], config.use_log_scale) }}", + classes="text-caption px-2 text-no-wrap", + ) + + +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 = {k: v["normal"] for k, v in COLORBAR_CACHE.items()} + self.state.luts_inverted = {k: v["inverted"] for k, v in COLORBAR_CACHE.items()} + + 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 + + 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] + + @hot_reload + 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 = [ + n for n in var_names if n != name + ] + with view.config.provide_as("config"): + with v3.VCol( + 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 = [n for n in all_names if n != name] + with view.config.provide_as("config"): + with v3.VCol( + 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 diff --git a/scripts/setup_tauri2.sh b/scripts/setup_tauri2.sh new file mode 100755 index 0000000..e0b27f3 --- /dev/null +++ b/scripts/setup_tauri2.sh @@ -0,0 +1,30 @@ +#!/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 quickview \ + --hidden-import pkgutil \ + --add-binary="$(which pvpython):." \ + 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 quickview.module + +# Precompile install to speedup start (maybe?) +./src-tauri/server/server --timeout 1 --server