diff --git a/.codespellrc b/.codespellrc index c6a15b93..5c5ba3b5 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,2 +1,2 @@ [codespell] -skip = CHANGELOG.md +skip = CHANGELOG.md,tests/data/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df677c78..29b4ce27 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" # Install and run pre-commit - run: | @@ -46,32 +46,32 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Set up NPM - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Vue component dependencies - run: npm ci - working-directory: pan3d-js - - name: Lint Vue components - run: npm run lint - working-directory: pan3d-js - - name: Build Vue components - run: npm run build - working-directory: pan3d-js - # Conditionally install pykdtree from source for MacOS only - name: Install pykdtree from source on macOS if: matrix.config.os == 'macos-latest' run: | export USE_OMP=0 pip install --no-binary pykdtree pykdtree>=1.3.13 + - name: Install OSMesa for Linux + if: matrix.config.os == 'ubuntu-latest' + run: sudo apt-get install -y libosmesa6-dev + + - name: Install and Run Tests (without viewer) + if: matrix.config.os == 'windows-latest' + run: | + pip install . + pip install -r tests/requirements.txt + pytest -s ./tests/test_xarray.py + pytest -s ./tests/test_builder.py + working-directory: . - - name: Install and Run Tests + - name: Install and Run Tests (with viewer) + if: matrix.config.os != 'windows-latest' run: | pip install . pip install -r tests/requirements.txt + pytest -s ./tests/test_xarray.py pytest -s ./tests/test_builder.py - pip install .[geotrame] + pip install .[viewer] pytest -s ./tests/test_viewer.py working-directory: . diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 627a65d9..7bde185c 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" # Install and run pre-commit - run: | @@ -28,7 +28,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9"] + python-version: ["3.10"] config: - { name: "Linux", @@ -56,26 +56,17 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Set up NPM - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Vue component dependencies - run: npm ci - working-directory: pan3d-js - - name: Lint Vue components - run: npm run lint - working-directory: pan3d-js - - name: Build Vue components - run: npm run build - working-directory: pan3d-js + - name: Install OSMesa for Linux + if: matrix.config.os == 'ubuntu-latest' + run: sudo apt-get install -y libosmesa6-dev - name: Install and Run Tests run: | pip install . pip install -r tests/requirements.txt + pytest -s ./tests/test_xarray.py pytest -s ./tests/test_builder.py - pip install .[geotrame] + pip install .[viewer] pytest -s ./tests/test_viewer.py working-directory: . @@ -97,21 +88,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" - - - name: Set up NPM - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Vue component dependencies - run: npm ci - working-directory: pan3d-js - - name: Lint Vue components - run: npm run lint - working-directory: pan3d-js - - name: Build Vue components - run: npm run build - working-directory: pan3d-js + python-version: "3.10" # https://python-semantic-release.readthedocs.io/en/latest/migrating_from_v7.html#repurposing-of-version-and-publish-commands - name: Python Semantic Release diff --git a/.gitignore b/.gitignore index 20159639..d3213a43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store node_modules -.venv +.venv* +*.nc +*.data-origin.json examples/jupyter/*.gif site/* diff --git a/docs/README.md b/docs/README.md index a7ec2ba1..d9de2649 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,13 +19,13 @@ For an introduction to this project, check out our [blog post][blog-post-link]. ## Installation -To install requirements for the Pan3D DatasetBuilder class only: +To install requirements for the Pan3D VTK mesh builder class only: pip install pan3d -To install requirements for the GeoTrame user interface: +To install requirements for the graphical user interface (viewers + explorers): - pip install "pan3d[geotrame]" + pip install "pan3d[viewer]" **Optional**: to install requirements for Pangeo and ESGF catalogs, respectively: @@ -39,43 +39,42 @@ To install requirements for the GeoTrame user interface: ## Quick Start -`geotrame` is the main entrypoint for launching GeoTrame locally. Below are some example usages. +`xr-viewer` is the main entrypoint for launching XArray Viewer locally. Below are some example usages. -To launch GeoTrame without a target dataset to browse XArray examples: +To launch XArray Viewer without a target dataset to browse XArray examples: - geotrame + xr-viewer -To launch GeoTrame with a local path to a target dataset: +To launch XArray Viewer with a local path to a target dataset: - geotrame --dataset=/path/to/dataset.zarr + xr-viewer --xarray-file ./examples/example_dataset.nc -To launch GeoTrame with a remote URL to a target dataset: +To launch XArray Viewer with a remote URL to a target dataset: - geotrame --dataset=https://host.org/link/to/dataset.zarr + xr-viewer --xarray-url https://host.org/link/to/dataset.zarr -To launch GeoTrame with a compatible configuration file (see [examples][examples-link]): +To launch XArray Viewer with a compatible configuration file (see [examples][examples-link]): - geotrame --config_path=/path/to/pan3d_state.json + xr-viewer --import-state ./examples/example_config_xarray.json -To launch GeoTrame with the option to browse the Pangeo and ESGF Dataset Catalogs (see [Catalogs Tutorial](tutorials/catalogs.md)): +To launch the Catalog browser will allow you to query the Pangeo and ESGF Dataset Catalogs (see [Catalogs Tutorial](tutorials/catalogs.md)) depending on the available dependencies: - geotrame --catalogs pangeo esgf + xr-catalog -Or you may specify only one catalog: +You may have to install the required dependencies: - geotrame --catalogs pangeo - - geotrame --catalogs esgf + pip install "pan3d[pangeo]" + pip install "pan3d[esgf]" -> The `geotrame` entrypoint will automatically launch your default browser to open `localhost:8080`. +> The `xr-viewer` entrypoint will automatically launch your default browser to open `localhost:8080`. > > To launch without opening your browser, add the `--server` argument to your command. ## Tutorials -- [How to use GeoTrame](tutorials/dataset_viewer.md) +- [How to use XArray Viewer](tutorials/dataset_viewer.md) - [GeoTrame command line](tutorials/command_line.md) - [Catalogs Tutorial](tutorials/catalogs.md) - [How to use Pan3D in a Jupyter notebook](tutorials/jupyter_notebook.md) diff --git a/docs/api/configuration.md b/docs/api/configuration.md index c58f4997..11d7378d 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -2,41 +2,40 @@ ## Introduction -Pan3D uses JSON files to save an application state for reuse. The GeoTrame UI and the Pan3D DatasetBuilder API include access to import and export functions which read and write these configuration files, respectively. This documentation provides guidelines for reading and writing these files manually. +Pan3D uses JSON files to save an application state for reuse. The XArray Viewer and the Pan3D Dataset builder enable import and export features to quickly get back to the data or state you've left off. This documentation provides guidelines for reading and writing these files manually. -There are five sections available in the configuration file format: `data_origin`, `data_array`, `data_slices`, `ui`, and `render`. The values in these sections will be passed to various attributes on the current `DatasetBuilder` instance and, if applicable, the corresponding `DatasetViewer` instance state. +There are two sections dedicated to configure the data access and VTK mesh extraction while the two remaining are specific for the viewer and rendering setup. The core and mandatory part is the `data_origin` section which provide information on where the data is located. Then we have the `dataset_config` which capture the pieces we want to load from the XArray dataset to produce a VTK mesh. The `preview` section is to configure the default viewer in term of color and scaling. Finally the `camera` gather any specific camera location so you can see the exact same thing as when you saved a given state. All of those sections are optional except the `data_origin` and `dataset_config` is the generated mesh is important to you. ## Example ``` { - "data_origin": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", - "data_array": { - "name": "analysed_sst", + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", + "order": "C" + }, + "dataset_config": { "x": "lon", "y": "lat", "t": "time", - "t_index": 5 - }, - "data_slices": { - "lon": [1000, 6000, 20], - "lat": [500, 3000, 20], - }, - "ui": { - "main_drawer": false, - "axis_drawer": false, - "expanded_coordinates": [] + "arrays": [ + "analysed_sst" + ], + "t_index": 5, + "slices": { + "lon": [1000, 6000, 20], + "lat": [500, 3000, 20] + } }, - "render": { - "auto": false, - "x_scale": 1, - "y_scale": 1, - "z_scale": 1, - "scalar_warp": false, - "cartographic": false, - "transparency": false, - "transparency_function": "linear", - "colormap": "viridis" + "preview": { + "color_by": "analysed_sst", + "color_preset": "Cool to Warm", + "color_min": 271.1499938964844, + "color_max": 307.25, + "scale_x": 1, + "scale_y": 1, + "scale_z": 1 } } ``` @@ -45,61 +44,56 @@ For more example configuration files, visit our [Examples on Github](https://git ## `data_origin` (Required) -The value for this key may be a string or dictionary. If this value is a string, it should contain a local path or remote URL referencing a target dataset readable by `xarray.open_dataset`. If this value is a dictionary, it should adhere to the following schema. +The value for this key is a dictionary that should adhere to the following schema. | Key | Required? | Type | Value Description | |-----|-----------|------|-------------------| -| `source` | NO (default="default") | `str` | A string specifying a module to interpret the value for `id`. Options include "default", "xarray", "pangeo", "esgf". | +| `source` | YES | `str` | A string specifying a module to interpret the value for `id`. Options include "file", "url", "xarray", "pangeo", "esgf". | | `id` | YES | `str` | A unique identifier of the target dataset. Depending on the value for `source`, this may be a path, url, name, or other unique id. | +| `order` | NO | `str` | Specify the order convention which can either be `F` (Fortran) or `C`. The default is `C` | -## `data_array` (Required) +## `dataset_config` (Optional) The value for this key should be a mapping specifying how to interpret the information in the target dataset. The following table describes keys available in this mapping schema. | Key | Required? | Type | Value Description | |-----|-----------|------|-------------------| -|`name`|YES |`str` |The field that will be mapped onto a mesh for rendering. This should be a name of an array that exists in the current dataset. This value will be passed to `DatasetBuilder.data_array_name`. | -|`x` |NO (default=None) |`str`|The world coordinate value along X describing the grid/mesh. This should be the name of a coordinate that exists in the data array. This value will be passed to `DatasetBuilder.set_data_array_axis_names`.| -|`y` |NO (default=None) |`str`|The world coordinate value along Y describing the grid/mesh. This should be the name of a coordinate that exists in the data array. This value will be passed to `DatasetBuilder.set_data_array_axis_names`.| -|`z` |NO (default=None) |`str`|The world coordinate value along Z describing the grid/mesh. This should be the name of a coordinate that exists in the data array. This value will be passed to `DatasetBuilder.set_data_array_axis_names`.| -|`t` |NO (default=None) |`str`|The coordinate name that represents slices of data, which may be time. Unlike other axes, this axis can only show one index at a time. This should be the name of a coordinate that exists in the data array. This value will be passed to `DatasetBuilder.set_data_array_axis_names`.| -|`t_index` |NO (default=0)|`int`|The index of the current time slice. Must be an integer >= 0 and < the length of the current time coordinate.This value will be passed to `DatasetBuilder.set_data_array_time_index`.| - -## `data_slices` (Optional) -The value for this key should be a mapping of coordinate names (which are likely used as values for `x` | `y` | `z` | `t` in the `data_array` section) to slicing arrays. This mapping will be formatted and passed to `DatasetBuilder.set_data_array_coordinates`. - -Each slicing array should be a list of three values `[start, stop, step]`. +|`x` |NO (default=None) |`str`|The world coordinate value along X describing the grid/mesh. This should be the name of a coordinate that exists in the data array. | +|`y` |NO (default=None) |`str`|The world coordinate value along Y describing the grid/mesh. This should be the name of a coordinate that exists in the data array. | +|`z` |NO (default=None) |`str`|The world coordinate value along Z describing the grid/mesh. This should be the name of a coordinate that exists in the data array. | +|`t` |NO (default=None) |`str`|The coordinate name that represents slices of data, which may be time. Unlike other axes, this axis can only show one index at a time. This should be the name of a coordinate that exists in the data array. | +|`t_index` |NO (default=0)|`int`|The index of the current time slice. Must be an integer >= 0 and < the length of the current time coordinate. | +|`arrays` |NO (default=[])|`list[str]`|The set of array names we want the output mesh to contains. | +|`slices` |NO (default={})|`dict`|The set of slices and indexes performing a selection on the XArray dataset. It is a dictionary where keys are to the various coordinates array that we want to filter and the values can either define a slice (`[start, stop, step]`) or an index (`int`). | -`start`: the index at which the sliced data should start (inclusive) +__Slice explained:__ +- `start`: the index at which the sliced data should start (inclusive) +- `stop`: the index at which the sliced data should stop (exclusive) +- `step`: an integer > 0 which represents the number of items to skip when slicing the data (e.g. step=2 represents 0.5 resolution) -`stop`: the index at which the sliced data should stop (exclusive) +## `preview` (Optional) -`step`: an integer > 0 which represents the number of items to skip when slicing the data (e.g. step=2 represents 0.5 resolution) - -## `ui` (Optional) -The value for this key should be a mapping of any number of UI state values. The following table describes keys available in this mapping schema. +This section is only relevant when using the default XArray Viewer from Pan3D. It capture what to display and how. +The following table describes keys available in this mapping schema. | Key | Required? | Type | Value Description | |-----|-----------|------|-------------------| -|`main_drawer`|NO (default=False)|`bool`|If true, open the lefthand drawer for dataset and data array browsing/selection.| -|`axis_drawer`|NO (default=False)|`bool`|If true, open the righthand drawer for axis assignment/slicing. **Note:** By default, this becomes True when a data array is selected.| -|`unapplied_changes`|NO (default=False)|`bool`|If true, show "Apply and Render" button, which when clicked will apply any unapplied changes and rerender.| -|`error_message`|NO (default=None)|`str`|If not None, this string will show as the error message above the render area.| -|`more_info_link`|NO (default=None)|`str`|If not None, this string should contain a link to more information about the current dataset. This link will appear below the dataset selection box.| -|`expanded_coordinates`|NO (default=`[]`)|`list[str]`|This list should contain the names of all coordinates which should appear expanded in the righthand axis drawer. **Note:** By default, this list is populated with all available coordinate names once the data array is selected.| - - -## `render` (Optional) -The value for this key should be a mapping of any number of render state values. The following table describes keys available in this mapping schema. - +|`view_3d`|NO (default=True)|`bool`|If true, the 3D interaction will rotate the dataset. Otherwise, a 2D interaction will be used (pan instead of rotate) along with parallel projection.| +|`color_by`|NO (default=None)|`str`|Name of a loaded array that we want to display.| +|`color_preset`|NO (default='Cool to Warm')|`str`|Name of a color preset for scalar mapping.| +|`color_min`|NO (default=None)|`float`|Scalar value that will be mapped to the lower end of the color scale.| +|`color_max`|NO (default=None)|`float`|Scalar value that will be mapped to the upper end of the color scale.| +|`scale_x`|NO (default=`1`)|`float`|Rendering scale to apply on the X axis.| +|`scale_y`|NO (default=`1`)|`float`|Rendering scale to apply on the Y axis.| +|`scale_z`|NO (default=`1`)|`float`|Rendering scale to apply on the Z axis.| + + +## `camera` (Optional) +The value for this key configure the VTK camera for any specific viewer or explorer. | Key | Required? | Type | Value Description | |-----|-----------|------|-------------------| -|`auto`|NO (default=True)|`bool`|If true, apply changes and rerender every time a configuration change is made.| -|`x_scale`|NO (default=1)|`int`|The relative scale of the X axis in the rendered scene.| -|`y_scale`|NO (default=1)|`int`|The relative scale of the Y axis in the rendered scene.| -|`z_scale`|NO (default=1)|`int`|The relative scale of the Z axis in the rendered scene.| -|`scalar_warp`|NO (default=False)|`bool`|If true, Apply scalar warping to the rendered mesh (extrude values in z-axis proportional to their magnitude).| -|`cartographic`|NO (default=False)|`bool`|If true, render the data wrapped around an earth sphere.| -|`transparency`|NO (default=False)|`bool`|If true, enable transparency mode for the rendered mesh, applying the current transparency function.| -|`transparency_function`|NO (default="linear")|`str`|The name of the transparency function to apply when transparency is enabled. Options are "linear", "linear_r", "geom", "geom_r", "sigmoid", and "sigmoid_r".| -|`colormap`|NO (default="viridis")|`str`|The name of the colormap to apply to the rendered mesh. Any matplotlib colormap name is a valid value.| +|`position`|NO (default=None)|`list[float]`|3D Coordinate of the camera position.| +|`view_up`|NO (default=None)|`list[float]`|Vector defining the vertival axis.| +|`focal_point`|NO (default=None)|`list[float]`|3D Coordinate of where the camera is looking at.| +|`parallel_projection`|NO (default=0)|`int`|Either 1 or 0 to define if the camera should use perpective or parallel projection.| +|`parallel_scale`|NO (default=None)|`float`|Zooming factor when `parallel_projection=1`.| diff --git a/docs/api/dataset_builder.md b/docs/api/dataset_builder.md index 3a192e97..44d2687e 100644 --- a/docs/api/dataset_builder.md +++ b/docs/api/dataset_builder.md @@ -1,6 +1,6 @@ -# [DatasetBuilder][module-link] API Reference +# [vtkXArrayRectilinearSource][module-link] API Reference -[module-link]: https://github.com/Kitware/pan3d/blob/main/pan3d/dataset_builder.py +[module-link]: https://github.com/Kitware/pan3d/blob/main/pan3d/xarray/algorithm.py -::: pan3d.dataset_builder.DatasetBuilder +::: pan3d.xarray.algorithm.vtkXArrayRectilinearSource handler: python diff --git a/docs/api/dataset_viewer.md b/docs/api/dataset_viewer.md index 756e7031..3e515489 100644 --- a/docs/api/dataset_viewer.md +++ b/docs/api/dataset_viewer.md @@ -1,6 +1,6 @@ -# [DatasetViewer][module-link] API Reference +# [XArrayViewer][module-link] API Reference -[module-link]: https://github.com/Kitware/pan3d/blob/main/pan3d/dataset_viewer.py +[module-link]: https://github.com/Kitware/pan3d/blob/main/pan3d/viewers/preview.py -::: pan3d.dataset_viewer.DatasetViewer +::: pan3d.viewers.preview.XArrayViewer handler: python diff --git a/docs/images/xr-viewer-00.png b/docs/images/xr-viewer-00.png new file mode 100644 index 00000000..da439e30 Binary files /dev/null and b/docs/images/xr-viewer-00.png differ diff --git a/docs/images/xr-viewer-01.png b/docs/images/xr-viewer-01.png new file mode 100644 index 00000000..79f3988a Binary files /dev/null and b/docs/images/xr-viewer-01.png differ diff --git a/docs/images/xr-viewer-02.png b/docs/images/xr-viewer-02.png new file mode 100644 index 00000000..34873048 Binary files /dev/null and b/docs/images/xr-viewer-02.png differ diff --git a/docs/images/xr-viewer-03.png b/docs/images/xr-viewer-03.png new file mode 100644 index 00000000..f5ed541d Binary files /dev/null and b/docs/images/xr-viewer-03.png differ diff --git a/docs/images/xr-viewer-04.png b/docs/images/xr-viewer-04.png new file mode 100644 index 00000000..dd1bfda5 Binary files /dev/null and b/docs/images/xr-viewer-04.png differ diff --git a/docs/images/xr-viewer-05.png b/docs/images/xr-viewer-05.png new file mode 100644 index 00000000..8e063183 Binary files /dev/null and b/docs/images/xr-viewer-05.png differ diff --git a/docs/images/xr-viewer-06.png b/docs/images/xr-viewer-06.png new file mode 100644 index 00000000..2290036d Binary files /dev/null and b/docs/images/xr-viewer-06.png differ diff --git a/docs/images/xr-viewer-07.png b/docs/images/xr-viewer-07.png new file mode 100644 index 00000000..309e6be0 Binary files /dev/null and b/docs/images/xr-viewer-07.png differ diff --git a/docs/images/xr-viewer-08.png b/docs/images/xr-viewer-08.png new file mode 100644 index 00000000..64dfc50d Binary files /dev/null and b/docs/images/xr-viewer-08.png differ diff --git a/docs/images/xr-viewer-09.png b/docs/images/xr-viewer-09.png new file mode 100644 index 00000000..d6ff2d91 Binary files /dev/null and b/docs/images/xr-viewer-09.png differ diff --git a/docs/images/xr-viewer-10.png b/docs/images/xr-viewer-10.png new file mode 100644 index 00000000..f1f24709 Binary files /dev/null and b/docs/images/xr-viewer-10.png differ diff --git a/docs/images/xr-viewer-11.png b/docs/images/xr-viewer-11.png new file mode 100644 index 00000000..7c66ecf8 Binary files /dev/null and b/docs/images/xr-viewer-11.png differ diff --git a/docs/images/xr-viewer-12.png b/docs/images/xr-viewer-12.png new file mode 100644 index 00000000..e3029bb4 Binary files /dev/null and b/docs/images/xr-viewer-12.png differ diff --git a/docs/images/xr-viewer-13.png b/docs/images/xr-viewer-13.png new file mode 100644 index 00000000..fab3bd3e Binary files /dev/null and b/docs/images/xr-viewer-13.png differ diff --git a/docs/images/xr-viewer-14.png b/docs/images/xr-viewer-14.png new file mode 100644 index 00000000..68cb4541 Binary files /dev/null and b/docs/images/xr-viewer-14.png differ diff --git a/docs/images/xr-viewer-jupyter-00.png b/docs/images/xr-viewer-jupyter-00.png new file mode 100644 index 00000000..0406f83f Binary files /dev/null and b/docs/images/xr-viewer-jupyter-00.png differ diff --git a/docs/images/xr-viewer-jupyter-01.png b/docs/images/xr-viewer-jupyter-01.png new file mode 100644 index 00000000..4afc3074 Binary files /dev/null and b/docs/images/xr-viewer-jupyter-01.png differ diff --git a/docs/tutorials/catalogs.md b/docs/tutorials/catalogs.md index 3419a291..25e926f5 100644 --- a/docs/tutorials/catalogs.md +++ b/docs/tutorials/catalogs.md @@ -2,6 +2,8 @@ ## Introduction +> Need to fix/update content with new code base + Pan3D includes catalog modules to explore two existing third-party dataset repositories: the Pangeo datastore and the **E**arth **S**ystem **G**rid **F**ederation (ESGF) 2 datastore. The [Pangeo Catalog][pangeo-info] uses the Python package `intake` ([docs][pangeo-intake]) and the [ESGF Catalog][esgf-info] uses the Python package `intake-esgf` ([docs][esgf-intake]). After installing the necessary requirements and specifying that the application should be launched with these catalogs enabled, GeoTrame includes a dialog in the user interface to perform attribute-based filtering on the datasets available in each datastore. After performing a filter search, the returned set of datasets will be grouped and made available in the sidebar, at which point you can select any dataset in the group to explore. diff --git a/docs/tutorials/command_line.md b/docs/tutorials/command_line.md index b318183f..ab090188 100644 --- a/docs/tutorials/command_line.md +++ b/docs/tutorials/command_line.md @@ -1,12 +1,12 @@ -# GeoTrame command line arguments +# XArray Viewer command line arguments -To use the GeoTrame CLI, be sure to install the Pan3D GeoTrame dependencies first. This includes Trame, a Kitware toolkit for Python servers. Learn more about Trame [here][trame-link]. +To use the xr-viewer CLI, be sure to install the Pan3D Viewer dependencies first. This includes Trame, a Kitware toolkit for Python servers. Learn more about Trame [here][trame-link]. - pip install pan3d[geotrame] + pip install pan3d[viewer] -By default, the command `geotrame` will launch GeoTrame as a local Python server and open a tab in your default browser and navigate to `localhost:8080`. You can change this behavior in a number of ways. For example, you can disable the browser tab launch by adding `--server` to the command. +By default, the command `xr-viewer` will launch XArray Viewer as a local Python server and open a tab in your default browser and navigate to `localhost:8080`. You can change this behavior in a number of ways. For example, you can disable the browser tab launch by adding `--server` to the command. -In response, `geotrame` will display this message in the terminal: +In response, `xr-viewer` will display this message in the terminal: App running at: - Local: http://localhost:8080/ @@ -17,15 +17,14 @@ As the message indicates, pointing a browser to http://localhost:8080/ will open There are other arguments to initialize features and data. Here is the full list: ```bash ---help/-h: Write command info including the list of options to the terminal and exit. ---server: Launch in server mode, which disables the default behavior of opening a browser tab on startup. ---dataset: Pass a string with this argument to specify a target dataset. This value can be either a local path or remote URL. This value must be readable by `xarray.open_dataset()`. ---config_path: Pass a string with this argument to specify a startup configuration. This value must be a local path to a JSON file which adheres to the schema specified in the [Configuration Files documentation](../api/configuration.md). A dataset specified in this configuration will override any value passed to `--dataset`. ---resolution: Pass a numeric string with this argument to specify a default resolution for rendering data. This value defaults to 128. It is recommended that this value should be a power of 2, but it is not required. If the value is 1 or less, the full image resolution will be used, and interface options will become available for manually adjusting the slicing of each axis individually. ---catalogs: Pass one or more strings which reference available catalog modules (options include "pangeo", "esgf"). If specified, the Catalog Search interface will become available in the left sidebar. See the Catalog Search Tutorial for more information. ---debug: Launch in debug mode, which will include more terminal output. Intended for developer use. +--help/-h: Write command info including the list of options to the terminal and exit. +--server: Launch in server mode, which disables the default behavior of opening a browser tab on startup. +--xarray-file: Provide path to xarray file. +--xarray-url: Provide URL to xarray dataset. +--import-state: Pass a string with this argument to specify a startup configuration. This value must be a local path to a JSON file which adheres to the schema specified in the [Configuration Files documentation](../api/configuration.md). A dataset specified in this configuration will override any value passed to `--xarray-*`. +--debug: Launch in debug mode, which will include more terminal output. Intended for developer use. ``` -[trame-link]: https://trameapp.kitware.com/ +[trame-link]: https://kitware.github.io/trame/ diff --git a/docs/tutorials/dataset_viewer.md b/docs/tutorials/dataset_viewer.md index 377f38a8..bf02d012 100644 --- a/docs/tutorials/dataset_viewer.md +++ b/docs/tutorials/dataset_viewer.md @@ -1,4 +1,4 @@ -# GeoTrame Tutorial +# Pan3D XArray Viewer Tutorial ## Introduction @@ -11,212 +11,186 @@ For more information about this dataset, visit [the C3S Catalog](https://surfobs To follow along this tutorial, install Pan3D with GeoTrame enabled. ``` -pip install pan3d[geotrame] +pip install pan3d[viewer] ``` -Run GeoTrame as a local python server with the following command. +Run XArray Viewer as a local python server with the following command. ``` -geotrame --dataset=https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/pangeo-forge/EOBS-feedstock/eobs-tg-tn-tx-rr-hu-pp.zarr +xr-viewer --xarray-url https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/pangeo-forge/EOBS-feedstock/eobs-tg-tn-tx-rr-hu-pp.zarr ``` -GeoTrame will open as a tab in your default browser. You can also visit `localhost:8080` in another browser. +Pan3D XArray Viewer will open as a tab in your default browser. You can also visit `localhost:8080` in another browser. > **Note:** to prevent the behavior of opening a tab on startup, add `--server` to the above command to run server mode. - > **Note:** This tutorial also specifies a default render resolution of 2048 for higher quality images. You may specify a render resolution by adding `--resolution=[value]` to the above command. A higher resolution value will result in longer rendering times. If not specified, a default render resolution of 128 will be used for faster renders. - -## Using the GeoTrame Viewer +## Using the XArray Viewer #### Data selection -After a moment to load the data from the remote URL, GeoTrame will render the default configuration of the target dataset. This dataset contains daily weather data recorded over the European continent between years 1950 and 2020. When the dataset first loads, GeoTrame displays the first array ("hu") at the first time step (01-January-1950). - -![](../images/1.png) - - -You can open the left drawer by clicking on the dataset configuration icon in the top left. - -![](../images/2.png) - -Inside this panel, you will find the following information: - -- A group selection box. Its current value is "default". The only dataset in the default group is the one we passed as an argument. There is one more available group option in this selection box: the "xarray" group contains example Xarray datasets to explore. More groups can be added with the Catalog Search (See the [Catalog Search tutorial](catalogs.md) for details). -- A dataset selection box. This selection box contains all the datasets available in the currently selected group. Its current value is the url we passed as an argument, which is the only dataset available in the default group. -- A button to view the attributes of the current dataset. Click the three-dots icon next to "Attributes" to open a dialog table of metadata available on the dataset. - - ![](../images/3.png) - -- A list of arrays available in the dataset, each with a button to view its attributes. The arrays in this dataset are abbreviations, so we can open the attributes tables to see the standard names. The first array ("hu") is mean relative humidity, and we can see the unit is percentage. +After a moment to load the data from the remote URL, __xr-viewer__ will provide the data information of the target dataset. This dataset contains daily weather data recorded over the European continent between years 1950 and 2020. When the dataset first loads, xr-viewer list all the data available within the dataset without downloading anything more than its metadata. - ![](../images/4.png) +![](../images/xr-viewer-00.png) -You can select any array from this list by clicking on the name. For this tutorial, we will continue with the the mean temperature data in the array called "tg". After a moment to load the new array, the rendering will update. +Inside the left panel, you will find the following information: -#### Data configuration +__Data Origin__ -After selecting the "tg" array on the EOBS dataset, we will open the axis drawer on the right for further data configuration. To toggle the visibility of this drawer, click on the axis info icon in the top right corner. The axis configuration drawer allows us to change the default axis assignments and slicing. +The __Data Origin__ group gather information on where the data is located. So far we've started from a URL so both the source (Remote URL) and its id (actual URL) are captured to which allow the application to provide a state for reproducibility. -By default, "longitude" is assigned to X, "latitude" is assigned to Y, and "time" is assigned to T. This data does not have a Z coordinate, so our rendered meshes are all planes. We will explore a dataset with a Z axis later. +But you can pick other sources or ids. For `Local File` and `Remote URL`, a text field is provided as ID so you can enter the path or URL that you aim to load. If you select the `XArray Tutorial` option, you will get a drop down of the dataset names availables from the tutorial. -![](../images/5.png) +At the bottom of that section a __Load__ button is available to trigger the fetch of the XArray Dataset attributes. Once click, the __Data Information__ section will automatically expend and will be filled by the various arrays available for that given dataset. -We can expand any of these coordinate panels. When we expand longitude, we see the following information: - -- The attributes table of the coordinate. For longitude, the units are degrees east, and there are 705 values ranging from -25 to 45. - -- Inputs to adjust the slicing along the coordinate. For longitude, the default slicing starts at index 0, ends at index 705 (exclusive), and has a step of 1. When using the default render resolution of 128, the step will be 6. The step input is disabled when slicing is determined automatically from the `resolution` argument. Set the `resolution` value less than or equal to 1 to adjust the slice value for each coordinate manually. - -- A selection box to assign the coordinate to an axis. These coordinates have already been assigned to each axis automatically, so longitude is assigned to the X axis. - - -![](../images/6.png) - -> **Note:** Each time you change a value in this panel, GeoTrame will attempt to rerender. If you plan on making many changes before you want to rerender, disable the Auto Render feature with the checkbox in the top right. If Auto Render is disabled, a button will appear when you have made changes that have not been applied. You can click this button to trigger the rerender once you have made all changes. The button displays the total size of the data that will be loaded for the render. - -> ![](../images/6a.png) - -We can crop the rendered mesh and reduce its resolution by adjusting the slicing along these coordinates. After setting the start index to 200 and setting the stop index to 600, we get the following rendering. +__Data Information__ -![](../images/7.png) +When overlaying your mouse on the various arrays listed, additional information are provided like shown below. -Next, let's adjust another coordinate. Collapse the longitude panel and open the time panel. Instead of slicing options, a coordinate assigned to T will show a slider for selecting one time step to display. +![](../images/xr-viewer-01.png) -Note that the time coordinate has 25933 slices. GeoTrame will only load the data for the current time step, so we don't load data that we don't need to render. This means that for each time change, GeoTrame will fetch more data, but each fetch will be much faster than trying to load the whole dataset at once. +The one highlighted in the screenshot above is "hu", which is mean relative humidity, and we can see the unit is percentage. -GeoTrame displays the first time step (index 0) by default, which corresponds to January 01 1950. We can see from the attributes table that the time coordinate range begins with this time and ends with December 31 2020. +The next section is for configuring the __rendering__. -![](../images/8.png) +__Rendering__ -You can pick any index along this slider, and the label above will display the corresponding time step. Below, we have gone forward to June 02 2011, and Europe appears much warmer. +By filling the rendering section and clicking on "Update 3D view", you will actually trigger data download from the XArray source to produce a VTK data structure that will then be render in 3D. -![](../images/8a.png) +The picture below illustrate what this section contains when no input have been provided. -Data bounds may also be adjusted with the bounds configuration menu in the top left corner of the rendering area. Click the button with the sliders icon to toggle the visibility of this menu. We can close the axis drawer for now. +![](../images/xr-viewer-02.png) -The bounds configuration menu displays range sliders for each coordinate. These show the changes we made to longitude and time. Try adjusting these sliders and observe how the rendered data changes. +You can select several arrays to load and therefore enable into your VTK mesh. For this tutorial, we will continue with the the mean temperature data in the array called "tg". -![](../images/8b.png) +By selecting "tg" in the `Data arrays` drop down, the first selected will be chosen for you for the `Color By` drop down. But you are free to change it if you have loaded more than one array. -When "Interactive Preview" is enabled, a greyscale preview of one face of the data will appear, replacing two of the spatial coordinate sliders. By default, the preview image is of the -Z face, so it replaces the sliders for X and Y. +When the `Color By` get changed, the provided array is getting fetch for extracting its range and reporting it in the UI for further tuning if need be. -![](../images/8c.png) +If you are satisfied with the default color preset, axis cropping (default no crop), axis steps and representation scaling, you can click on the "Update 3D view" button, to fully generate the mesh and render it in the screen. -We can now adjust the bounds of the X and Y coordinates by manipulating the red box within the preview image. We can still use the time slider, and updating the time value will trigger an update of the preview image. +After a moment to load the new array, the rendering will update. -![](../images/8d.png) +![](../images/xr-viewer-03.png) -The bounds configuration menu offers more features for 3D renders when three coordinates are assigned to X, Y, and Z. We'll revisit this panel with another dataset after this. +#### Data Navigation -#### Render configuration +Once you are done with the __data selection__, you can close the left panel (X button on the top left). At that point you will be presented with a more streamlined interface. -Let's reset the bounds and close the data selection drawer to focus on the rendering area. There are many options to customize the appearance of the rendering within this space. +![](../images/xr-viewer-04.png) -1. We can move the camera around the rendered mesh by clicking and dragging. We can pan the camera by holding Shift while dragging, and we can roll the camera by holding Ctrl while dragging. We can move the camera toward the mesh and away from it by scrolling. We can see this mesh is a plane. We'll look at a 3D dataset soon. +The top toolbar spell out the current time (1984-97-17 00:00:00) while providing a convenient slider to navigate through time. On the right of it, you can select any available array to change the color mapping. -2. The color legend is also interactive. We can drag it to another edge of the scene, or we can resize it by using the white bounding bars that appear when we click on the legend. +On the right side, you see the 3D View toolbar that is always present. That toolbar is composed of the following set of actions: +- Toggle interaction lock: this allow to prevent any involontary camera manipulation by locking the 3D view. +- Reset Camera: this will reposition the camera to make the data fully fit in the 3D view. +- Toggle interaction mode: this allow to toggle between 3D rotation and 2D panning. +- Rotate left: this allow to roll the camera 90º on the left. +- Rotate right: this allow to roll the camera 90º on the right. +- Reset camera normal to X axis. +- Reset camera normal to Y axis. +- Reset camera normal to Z axis. +- Reset camera at an angle. - ![](../images/9.png) +At the bottom, you have an interactive Scalar Bar that show the scalar range but also can give you the scalar value of a given color. Just hover your mouse on it to see the corresponding selected value. The picture below illustrate what you can expect. -3. The button with the three-dots icon in the top left opens a Views menu. Beside the three-dots icon, there are 12 buttons for you to try. +![](../images/xr-viewer-05.png) - ![](../images/9a.png) +#### Advanced Data selection - This menu contains the following options: +Sometime, you don't want to download the full dataset domain and it might be better to only fetch a subset of longitude and latitude. - 1. A button to reset the camera position and re-center the mesh. - 2. A button to set the camera in a perspective view. - 3. A button to put the camera on the X axis (our plane will be invisible from this view). - 4. A button to put the camera on the Y axis (our plane will be invisible from this view). - 5. A button to put the camera on the Z axis (this is the default view for our plane). - 6. A button to toggle edge visibility (with our current high resolution, there are a lot of edges. Try zooming in when you enable this). - 7. A button to toggle bounding box visibility (this will draw a thin border around our plane when enabled). - 8. A button to toggle ruler visibility (these will show our latitude and longitude scales). - 9. A button to toggle an axis widget's visibility (this will appear in the bottom left. Try rotating the scene while this is enabled). - 10. A button to toggle between local and remote rendering mode. Local rendering is the default and is recommended for basic use cases. - 11. A button to save the current visual as a static PNG file. - 12. A button to save the current rendering as an interactive HTML scene. +For instance, with the dataset loaded, we can adjust the longitude. -4. The button with the gear icon in the top right opens a rendering customization menu. This menu contains five customization options. +Based on the metadata, we know that the units are degrees east, and there are 705 values ranging from -25 to 45. - ![](../images/9b.png) +![](../images/xr-viewer-06.png) - 1. A colormap selection box. The default is "viridis". These options come from Matplotlib. - 2. A checkbox to enable transparency. When enabled, another selection box will appear with options for transparency function. The default is "linear". - 3. A checkbox to enable scalar warping. When enabled, scalar warping turns the rendered flat plane into a 3D mesh, where values are extruded in the Z axis according to their magnitudes. - 4. A checkbox to enable cartographic mode. When enabled, cartographic mode displays the data projected onto an earth sphere. This works best for data with latitude and longitude coordinates. - 5. Inputs to specify the relative scales of each axis. By default, this is a 1:1:1 ratio. +To adjust the slicing along the coordinate. For longitude, the default slicing starts at index 0, ends at index 705 (exclusive), and has a step of 1. -> **Note:** Scalar warping and cartographic mode are mutually exclusive. +![](../images/xr-viewer-07.png) -Since we have data with latitude and longitude, enable cartographic mode to see the data on a globe. +> **Note:** Each time you change a value in this panel, xr-viewer will attempt to rerender. -![](../images/10.png) +We can crop the rendered mesh and reduce its resolution by adjusting the slicing along these coordinates. After setting the start index to 200 and setting the stop index to 600, we get the following rendering. -By using the other configuration options, we can get a rendering like the one shown below. For the following rendering, we do the following: +![](../images/xr-viewer-08.png) -- move the color legend -- change the colormap to "plasma" -- enable transparency and change the transparency function to "linear_r" (which means reverse linear) -- disable cartographic mode -- enable scalar warping -- change the axis scale ratio to 2:2:1 so the scalar warping will be less extreme -- reset the camera to perspective view +On top of that cropping, we can also adjust the stepping to load even less data. In the picture below we are actually extracting 1/10 values across each axis. -![](../images/10a.png) +![](../images/xr-viewer-09.png) -Take a moment to try out different combinations to see how else this data can be configured to appear. #### Saving configurations -GeoTrame is intended to allow scientists to explore a dataset and find ideal visualizations with these many configuration options. Once you have found a visualization you like, you can use the PNG and HTML export options in the Views menu, but you can also export this configuration for fast replication with GeoTrame or the Pan3D DatasetBuilder. - -After we finish our configuration and have a finalized rendering, we can click the "Export" button in the top toolbar. Clicking this button will open a dialog, which asks for a location to save a configuration file. - -![](../images/11.png) +XArray Viewer is intended to allow scientists to explore a dataset and find the actual subset that is relevant for further exploration or analysis. For that reason, you can save a configuration file that can then be reused when loading the data for another visualization tool like the Pan3D explorers or any PyVista based visualization. -Some browsers, like Chrome, will allow specification of a download folder when you click on this input. Other browsers, like Firefox, do not allow this feature and will save the file to your default Downloads folder. By default, this file will be called `pan3d_state.json`. In browsers where you can specify download location, you can also change this name. +To access the __Export__ feature, you need to have the left panel open and click on the "Import/Export" icon like shown in the screenshot below. -Once you have specified a download location, a file will be saved to your computer and the dialog will change. +![](../images/xr-viewer-10.png) -![](../images/11a.png) +By clicking on the `Export state file`, a file will be downloaded with the `xarray-state.json` name. For our configuration, the contents of the exported file appear as follows: ``` { - "data_origin": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/pangeo-forge/EOBS-feedstock/eobs-tg-tn-tx-rr-hu-pp.zarr", - "data_array": { - "name": "tg", - "x": "longitude", - "y": "latitude", - "t": "time", - "t_index": 22432 - }, - "data_slices": { - "time": [0, 25932, 1], - "latitude": [0, 464, 1], - "longitude": [0, 704, 1] - }, - "ui": { - "main_drawer": false, - "axis_drawer": false, - "unapplied_changes": false, - "error_message": null, - "more_info_link": null, - "expanded_coordinates": [], + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/pangeo-forge/EOBS-feedstock/eobs-tg-tn-tx-rr-hu-pp.zarr", + "order": "C" + }, + "dataset_config": { + "x": "longitude", + "y": "latitude", + "z": null, + "t": "time", + "slices": { + "time": 8644, + "longitude": [ + 199, + 600, + 10 + ], + "latitude": [ + 0, + 465, + 10 + ] }, - "render": { - "auto": true, - "x_scale": 2, - "y_scale": 2, - "z_scale": 1, - "scalar_warp": true, - "cartographic": false, - "transparency": true, - "transparency_function": "linear_r", - "colormap": "plasma" - } + "t_index": 8644, + "arrays": [ + "tg" + ] + }, + "preview": { + "view_3d": true, + "color_by": "tg", + "color_preset": "Cool to Warm", + "color_min": -3.1199999302625656, + "color_max": 33.85999924317002, + "scale_x": 1, + "scale_y": 1, + "scale_z": 1 + }, + "camera": { + "position": [ + 10.24986034998275, + 48.249860539223604, + 162.88522596047304 + ], + "view_up": [ + 0.0, + 1.0, + 0.0 + ], + "focal_point": [ + 10.24986034998275, + 48.249860539223604, + 0.0 + ], + "parallel_projection": 0, + "parallel_scale": 42.15779864439795 + } } ``` @@ -224,53 +198,27 @@ To learn more about the schema to which this configuration file adheres, visit t Now, a collaborator can easily replicate our rendering. Try to save the JSON contents above to a file on your computer and follow along as if you just received this file from a colleague. -Clicking the "Import" button in the top toolbar will open a similar dialog. Click the file input to select the location of the configuration file on your computer. - -![](../images/12.png) - -Once the file has been selected, another "Import" button will appear. After clicking this button, the dialog will change and GeoTrame will begin loading the configuration and applying the changes. - -![](../images/12a.png) - -After a moment to load, GeoTrame will render the replicated scene. +Clicking the "Import state file" menu item will restore the state of the data and application. These configuration files can be used as arguments in the local server startup command (see [Command Line instructions](command_line.md) for details) or can be used in a Jupyter notebook environment (see [Jupyter Notebook tutorial](jupyter_notebook.md) for details). #### Viewing other data -Open the left drawer again and select "xarray" in the Group selection box. The current dataset will be cleared and the Dataset selection box will be populated with seven example datasets from Xarray. You can look at any of these examples from Xarray and try out the configuration options we have reviewed. - -There is one dataset among these with 4D data, which means we can select a time slice and get a 3D render. Select "Xarray Examples - ERA-Interim analysis" to experiment with GeoTrame features on a 3D mesh. We'll use the array labeled "v", which represents wind velocity in m/s. - -![](../images/13.png) - -As mentioned previously, there is a feature in the bounds configuration menu that is only available for data that has coordinates assigned to X, Y, and Z. More options will be available in the face selection dropdown. - -![](../images/14.png) - -More specifically, the -Z and +Z faces are available when coordinates are assigned to X and Y, and the preview for Z faces will replace the X and Y range sliders. The -Y and +Y faces are available when coordinates are assigned to X and Z, and the previews will replace the X and Z sliders. The -X and +X faces are available when coordinates are assigned to Y and Z, and the previews will replace the Y and Z sliders. - -![](../images/15.png) - -We are viewing the -Z face by default, so the X and Y sliders are replaced by the preview image. We can adjust the bounds of X and Y with the red box just like before. Since we also have a coordinate assigned to Z on this dataset, the Z slider is still available. - -![](../images/16.png) - -Changing the Z bounds will affect what is shown in the preview image; changing the start value will trigger a change in the +Z face preview, and changing the stop value will trigger a change in the -Z face preview. This is indicated by a blue handle on the Z slider. +Open the left panel again and select "XArray Tutorial" in the Data Origin Group selection box. The current dataset will be cleared and the Dataset selection box will be populated with seven example datasets from the XArray Tutorial. You can look at any of these examples from Xarray and try out the configuration options we have reviewed. -![](../images/17.png) +There is one dataset among these with 4D data, which means we can select a time slice and get a 3D render. Select "eraint_uvz" to experiment with the viewer features on a 3D mesh. We'll use the array labeled "v", which represents wind velocity in m/s. -You can change the face used for the preview image with the selection box above the preview. If we select +Z, the other handle on the level slider becomes blue. +![](../images/xr-viewer-11.png) -To orient the selected face towards you, you can click the camera location button next to the face selection box. +No that we have a 3D dataset, we can use the slicing tool rather than the cropping one. To activate it, just click on the crop/slice icon on the right of the sliders to toggle across each mode. -![](../images/18.png) +On the pictures below, we are slicing along the Z axis. -Selecting faces along other axes will change which sliders are visible. Try viewing the preview for other faces, orienting the selected face toward you with the camera button, and adjusting bounds with the red box and sliders. +| ![](../images/xr-viewer-12.png) | ![](../images/xr-viewer-13.png) | ![](../images/xr-viewer-14.png) | +| --- | --- | --- | -![](../images/19.png) -This concludes the tutorial on how to use GeoTrame. Now you can try these features on your own data, or use [Catalogs](catalogs.md) to explore more public data. +This concludes the tutorial on how to use xr-viewer. Now you can try these features on your own data. [xarray-tutorials-link]: https://docs.xarray.dev/en/stable/generated/xarray.tutorial.open_dataset.html diff --git a/docs/tutorials/jupyter_notebook.md b/docs/tutorials/jupyter_notebook.md index 35c504c1..bfe5d405 100644 --- a/docs/tutorials/jupyter_notebook.md +++ b/docs/tutorials/jupyter_notebook.md @@ -1,64 +1,71 @@ -# How to use Pan3D and GeoTrame in a Jupyter Notebook +# How to use Pan3D and XArray Viewer in a Jupyter Notebook Running Pan3D in a Jupyter notebook allows data scientists to incorporate the tool into their existing workflows and can facilitate greater collaboration between teammates. This tutorial assumes you have a running Jupyter notebook. You can find examples at [notebook examples][notebook-examples-link] in the Pan3D code repository. You can run these examples on Binder [here][binder-link]. -![](../images/20.png) +![](../images/xr-viewer-jupyter-00.png) 1. In your current kernel, install Pan3D: pip install pan3d[all] -2. In the first cell of your notebook, initialize Pan3D’s DatasetBuilder and GeoTrame. +2. In the first cell of your notebook, initialize Pan3D’s XArray Viewer. - from pan3d import DatasetBuilder - builder = DatasetBuilder() - geotrame = builder.viewer + from pan3d.viewers.preview import XArrayViewer + + viewer = XArrayViewer() + await viewer.ready 3. Prepare a configuration for the builder to import. This can come from a previously exported Pan3D configuration file. An example of this is shown in [`example_config_xarray.json`][config-xarray-link]: - from pan3d import DatasetBuilder - config_path = '../example_config_xarray.json' - builder = DatasetBuilder() - builder.import_config(config_path) + import json + from pathlib import Path + + config = Path("../example_config_xarray.json") + if config.exists(): + viewer.import_state(json.loads(config.read_text())) + print("State loaded") + else: + print(f"Could not find example state in {config.resolve()}") + 4. You can alternatively create a configuration dictionary. See [`url_config.ipynb`][url-config-notebook-link] for an example of this: - from pan3d import DatasetBuilder config = { - 'data_origin': 'https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr', - 'data_array': { - 'name': 'analysed_sst', - 'x': 'lon', - 'y': 'lat', - 't': 'time', + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr" }, - 'data_slices': { - 'lon': [1000, 6000, 20], - 'lat': [500, 3000, 20], + "dataset_config": { + "arrays": ["analysed_sst"], + "slices": { + "lon": [1000, 6000, 20], + "lat": [500, 3000, 20] + }, }, } - builder = DatasetBuilder() - builder.import_config(config) -5. If you’d like finer control of the configuration process, you can call individual state setters on the builder by referring to the [API documentation](../api/dataset_builder.md) for the `DatasetBuilder` class. Refer to `manual_config.ipynb` for an example using these API methods: + viewer.import_state(config) + +5. Finally to display the visualization in Jupyter, you can run the following cell + + viewer.ui + +6. You can even share the visualization in PyVista as well by running the following cell assuming you've installed PyVista in your Python environment. - builder = DatasetBuilder() - builder.dataset_path = '../example_dataset.nc' - builder.data_array_name = 'density' - builder.x = 'length' - builder.y = 'width' - builder.z = 'height' - builder.t = 'second' - builder.t_index = 2 + import pyvista as pv -6. After configuring the builder instance, you can show GeoTrame as cell output. + plotter = pv.Plotter() + plotter.show() + + xarray_reader = viewer.source + actor = plotter.add_mesh(xarray_reader, scalars="analysed_sst", cmap='coolwarm') - geotrame = builder.viewer - await geotrame.ready - geotrame.ui + # sync viewer update + viewer.ctrl.view_update.add(plotter.render) -7. If you’d like to do more advanced rendering than GeoTrame allows, you can still use the DatasetBuilder class for mesh preparation. You can access the mesh with `builder.mesh` and use it in a PyVista rendering pipeline. Refer to `advanced_pyvista_rendering.ipynb` for an example of this technique, which leverages PyVista plotting to generate an animated GIF of timesteps in the dataset. +![](../images/xr-viewer-jupyter-00.png) +While in the example we are using an XArrayViewer to drive our dataset builder, we can create such data source outside of the viewer and rely on it to drive any kind of visualization. [notebook-examples-link]: https://github.com/Kitware/pan3d/tree/main/examples/jupyter [binder-link]: https://mybinder.org/v2/gh/Kitware/pan3d/main?labpath=examples%2Fjupyter diff --git a/examples/data/esgf.json b/examples/data/esgf.json new file mode 100644 index 00000000..cd51026c --- /dev/null +++ b/examples/data/esgf.json @@ -0,0 +1,6 @@ +{ + "data_origin": { + "source": "esgf", + "id": "CMIP6.PAMIP.MOHC.HadGEM3-GC31-MM.pdSST-futAntSIC.r102i1p1f2.day.sfcWind.gn.v20210311|eagle.alcf.anl.gov" + } +} diff --git a/examples/data/pangeo.json b/examples/data/pangeo.json new file mode 100644 index 00000000..1c58da60 --- /dev/null +++ b/examples/data/pangeo.json @@ -0,0 +1,6 @@ +{ + "data_origin": { + "source": "pangeo", + "id": "MITgcm_channel_flatbottom_02km_run01_phys_snap15D" + } +} \ No newline at end of file diff --git a/examples/example_config_cmip.json b/examples/example_config_cmip.json index 74907dcc..cb138d0a 100644 --- a/examples/example_config_cmip.json +++ b/examples/example_config_cmip.json @@ -1,14 +1,12 @@ { - "data_origin": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/cmip6-feedstock/test_surface.zarr", - "data_array": { - "name": "zos", - "x": "i", - "y": "j", - "t": "time" + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/cmip6-feedstock/test_surface.zarr" }, - "ui": { - "main_drawer": true, - "axis_drawer": true, - "expanded_coordinates": [] + "dataset_config": { + "arrays": ["zos"] + }, + "preview": { + "color_by": "zos" } } diff --git a/examples/example_config_esgf.json b/examples/example_config_esgf.json index 2503f6ec..6892d285 100644 --- a/examples/example_config_esgf.json +++ b/examples/example_config_esgf.json @@ -1,32 +1,12 @@ { - "data_origin": { - "source": "esgf", - "id": "CMIP6.PAMIP.MOHC.HadGEM3-GC31-MM.pdSST-futAntSIC.r102i1p1f2.day.sfcWind.gn.v20210311|eagle.alcf.anl.gov" - }, - "data_array": { - "name": "sfcWind", - "x": "lon", - "y": "lat", - "t": "time" - }, - "ui": { - "main_drawer": false, - "axis_drawer": true, - "unapplied_changes": false, - "error_message": null, - "more_info_link": null, - "expanded_coordinates": [], - "current_time_string": "Apr 01 2000 12:00" - }, - "render": { - "auto": true, - "x_scale": 1, - "y_scale": 1, - "z_scale": 1, - "scalar_warp": false, - "cartographic": false, - "transparency": false, - "transparency_function": "linear", - "colormap": "cividis" - } + "data_origin": { + "source": "esgf", + "id": "CMIP6.PAMIP.MOHC.HadGEM3-GC31-MM.pdSST-futAntSIC.r102i1p1f2.day.sfcWind.gn.v20210311|eagle.alcf.anl.gov" + }, + "dataset_config": { + "arrays": ["sfcWind"] + }, + "preview": { + "color_by": "sfcWind" + } } diff --git a/examples/example_config_noaa.json b/examples/example_config_noaa.json index 1c64177f..05111e0e 100644 --- a/examples/example_config_noaa.json +++ b/examples/example_config_noaa.json @@ -1,30 +1,17 @@ { - "data_origin": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", - "data_array": { - "name": "analysed_sst", - "x": "lon", - "y": "lat", - "t": "time", - "t_index": 5 - }, - "data_slices": { - "lat": [ - 0, - 500, - 5 - ], - "lon": [ - 0, - 500, - 5 - ] - }, - "ui": { - "main_drawer": false, - "axis_drawer": false, - "expanded_coordinates": [] - }, - "render": { - "cartographic": true + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr" + }, + "dataset_config": { + "arrays": ["analysed_sst"], + "t_index": 5, + "slices": { + "lat": [0, 3200, 50], + "lon": [550, 3600, 50] } + }, + "preview": { + "color_by": "analysed_sst" + } } diff --git a/examples/example_config_pangeo.json b/examples/example_config_pangeo.json index 867892c8..090c1784 100644 --- a/examples/example_config_pangeo.json +++ b/examples/example_config_pangeo.json @@ -1,34 +1,12 @@ { - "data_origin": { - "source": "pangeo", - "id": "MITgcm_channel_flatbottom_02km_run01_phys_snap15D" - }, - "data_array": { - "name": "T", - "x": "YC", - "y": "XC", - "z": "Z", - "t": "time", - "t_index": 0 - }, - "ui": { - "main_drawer": false, - "axis_drawer": false, - "unapplied_changes": false, - "error_message": null, - "more_info_link": null, - "expanded_coordinates": [], - "current_time_string": "139968000.0 seconds" - }, - "render": { - "auto": true, - "x_scale": 1, - "y_scale": 1, - "z_scale": 100, - "scalar_warp": false, - "cartographic": false, - "transparency": false, - "transparency_function": "linear", - "colormap": "viridis" - } + "data_origin": { + "source": "pangeo", + "id": "MITgcm_channel_flatbottom_02km_run01_phys_snap15D" + }, + "dataset_config": { + "arrays": ["T"] + }, + "preview": { + "color_by": "T" + } } diff --git a/examples/example_config_tutorial.json b/examples/example_config_tutorial.json new file mode 100644 index 00000000..8675cda1 --- /dev/null +++ b/examples/example_config_tutorial.json @@ -0,0 +1,34 @@ +{ + "data_origin": { + "source": "url", + "id": "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", + "order": "C" + }, + "dataset_config": { + "x": "lon", + "y": "lat", + "t": "time", + "arrays": ["analysed_sst"], + "t_index": 5, + "slices": { + "lon": [1000, 6000, 20], + "lat": [500, 3000, 20] + } + }, + "preview": { + "color_by": "analysed_sst", + "color_preset": "Cool to Warm", + "color_min": 271.1499938964844, + "color_max": 307.25, + "scale_x": 1, + "scale_y": 1, + "scale_z": 1 + }, + "camera": { + "position": [-98.15050480107477, -83.50869045811957, 523.163918104528], + "view_up": [0.013629864545575398, 0.9879006762359134, 0.15448780108828863], + "focal_point": [-5.475002288818359, -2.9749984741210938, 0.0], + "parallel_projection": 0, + "parallel_scale": 139.08361136330777 + } +} diff --git a/examples/example_config_xarray.json b/examples/example_config_xarray.json index 2d0c2a56..5cf69556 100644 --- a/examples/example_config_xarray.json +++ b/examples/example_config_xarray.json @@ -1,19 +1,51 @@ { - "data_origin": { - "source": "xarray", - "id": "eraint_uvz" + "data_origin": { + "source": "xarray", + "id": "eraint_uvz", + "order": "C" + }, + "dataset_config": { + "x": "longitude", + "y": "latitude", + "z": "level", + "t": "month", + "slices": { + "month": 0 }, - "data_array": { - "name": "z", - "x": "longitude", - "y": "latitude", - "z": "level", - "t": "month", - "t_index": 0 - }, - "ui": { - "main_drawer": true, - "axis_drawer": true, - "expanded_coordinates": [] - } -} + "t_index": 0, + "arrays": [ + "u", + "v", + "z" + ] + }, + "preview": { + "view_3d": true, + "color_by": "v", + "color_preset": "erdc_rainbow_dark", + "color_min": -14.062651643471892, + "color_max": 11.624951359641091, + "scale_x": 1, + "scale_y": 1, + "scale_z": "0.201" + }, + "camera": { + "position": [ + -220.55250607152138, + -173.50353604111396, + 872.1418296455945 + ], + "view_up": [ + 0.03426096578177488, + 0.9725964517100821, + 0.2299615801490718 + ], + "focal_point": [ + -0.375, + 0.0, + 105.525 + ], + "parallel_projection": 0, + "parallel_scale": 211.26404391187822 + } +} \ No newline at end of file diff --git a/examples/jupyter/advanced_pyvista_rendering.ipynb b/examples/jupyter/advanced_pyvista_rendering.ipynb index c71ebfc5..4f6ee27b 100644 --- a/examples/jupyter/advanced_pyvista_rendering.ipynb +++ b/examples/jupyter/advanced_pyvista_rendering.ipynb @@ -1,5 +1,19 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "ad035d18-0ae4-4205-95d9-8dc5879f95f5", + "metadata": {}, + "source": [ + "## PyVista example\n", + "\n", + "This notebook does require you to install the following \n", + "\n", + "```\n", + "pip install pan3d pyvista imageio\n", + "```" + ] + }, { "cell_type": "code", "execution_count": null, @@ -7,9 +21,13 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder\n", + "import json\n", + "import time\n", + "from pathlib import Path\n", + "\n", "import pyvista as pv\n", - "from datetime import datetime" + "\n", + "from pan3d.xarray.algorithm import vtkXArrayRectilinearSource" ] }, { @@ -19,15 +37,14 @@ "metadata": {}, "outputs": [], "source": [ - "start = datetime.now()\n", + "config = json.loads(Path(\"../example_config_esgf.json\").read_text())\n", "\n", - "# Use Pan3D DatasetBuilder to import existing config file\n", - "config_path = '../example_config_esgf.json'\n", - "builder = DatasetBuilder(catalogs=['esgf'])\n", - "builder.import_config(config_path)\n", + "start = time.time()\n", + "builder = vtkXArrayRectilinearSource()\n", + "builder.load(config)\n", + "end = time.time()\n", "\n", - "# Access PyVista Mesh on builder\n", - "print(builder.mesh)\n" + "print(f\"Loaded data in {end-start} seconds\")" ] }, { @@ -39,29 +56,32 @@ "source": [ "# This advanced GIF rendering requires imageio\n", "# https://tutorial.pyvista.org/tutorial/03_figures/d_gif.html\n", - "\n", + "start = time.time()\n", "plotter = pv.Plotter()\n", "\n", "# Open a GIF\n", "plotter.open_gif(\"esgf.gif\")\n", "\n", + "# If want to use algo\n", + "# plotter.add_mesh(builder, render=False, clim=[0, 22])\n", + "\n", "# Update T and write a frame for each updated position\n", "# GeoTrame showed that T ranges from Apr 01 2000 12:00 to May 30 2001 12:00, and it has 420 time steps\n", - "n_time_frames = 420\n", - "n_skip_frames = 1\n", - "for i in range(0, n_time_frames, n_skip_frames):\n", - " plotter.clear()\n", + "for i in range(builder.t_size):\n", " builder.t_index = i\n", - " actor = plotter.add_mesh(builder.mesh.warp_by_scalar(), render=False, clim=[0, 22])\n", "\n", " # Write a frame. This triggers a render.\n", + " plotter.clear()\n", + " plotter.add_mesh(pv.wrap(builder()).warp_by_scalar(), render=False, clim=[0, 22])\n", " plotter.write_frame()\n", "\n", "# Closes and finalizes GIF\n", "plotter.close()\n", + "end = time.time()\n", "\n", "# GIF generation takes about 2 mins for 420 frames (approximately 0.3 seconds to fetch and render each frame)\n", - "print(f'Saved esgf.gif. Took {datetime.now() - start} seconds.')" + "print(f\"Saved esgf.gif. Took {end - start} seconds.\")\n", + "print(\"./esgf.gif\")" ] }, { @@ -69,15 +89,15 @@ "id": "ca246280-e8cd-44f2-b4a2-44a211116030", "metadata": {}, "source": [ - "![LocalGIF](esgf.gif \"gif\")" + "![LocalGIF](./esgf.gif)" ] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -89,7 +109,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/computed_field.ipynb b/examples/jupyter/computed_field.ipynb new file mode 100644 index 00000000..96778097 --- /dev/null +++ b/examples/jupyter/computed_field.ipynb @@ -0,0 +1,144 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "88a3053b-a228-4099-8bd8-d92abaae317b", + "metadata": {}, + "outputs": [], + "source": [ + "from pan3d.xarray.algorithm import vtkXArrayRectilinearSource" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c66ae5de-ba4c-49f8-bcae-3572bd59cfa0", + "metadata": {}, + "outputs": [], + "source": [ + "source = vtkXArrayRectilinearSource()\n", + "source.load(\n", + " {\n", + " \"data_origin\": {\n", + " \"source\": \"xarray\",\n", + " \"id\": \"eraint_uvz\",\n", + " },\n", + " \"dataset_config\": {\n", + " \"arrays\": [\"u\", \"v\"],\n", + " },\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41a4ebf1-8b90-4709-8d4c-06efb3afcc51", + "metadata": {}, + "outputs": [], + "source": [ + "source.computed = {\n", + " \"_use_scalars\": [\"u\", \"v\"], # optional if empty\n", + " \"_use_vectors\": [], # optional if empty\n", + " \"vec\": \"(u * iHat) + (v * jHat)\",\n", + " \"m2\": \"u*u + v*v\",\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a3db2e2-70f5-41f9-b429-bc0d9c1674ab", + "metadata": {}, + "outputs": [], + "source": [ + "print(source())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3bb5924-a1f4-4f0b-8f14-77ccbc66b251", + "metadata": {}, + "outputs": [], + "source": [ + "import pyvista as pv\n", + "\n", + "plotter = pv.Plotter()\n", + "plotter.add_mesh(source, scalars=\"m2\", cmap=\"coolwarm\")\n", + "plotter.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8b304583-81f0-4620-9581-64eed5d78433", + "metadata": {}, + "source": [ + "## Create UI to drive source + viz" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5a338fe-6997-4f2e-a237-d972415d1249", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets\n", + "\n", + "\n", + "def update_level(change):\n", + " source.slices = {\n", + " **source.slices,\n", + " source.z: change.new,\n", + " }\n", + " plotter.render()\n", + "\n", + "\n", + "def update_resolution(change):\n", + " v = source.slices\n", + " v[source.x] = [0, -1, change.new]\n", + " v[source.y] = [0, -1, change.new]\n", + " source.slices = v\n", + " plotter.render()\n", + "\n", + "\n", + "slider_level = widgets.IntSlider(\n", + " value=0,\n", + " min=0,\n", + " max=(source.input[source.z].size - 1),\n", + " step=1,\n", + " description=source.z,\n", + ")\n", + "slider_level.observe(update_level, names=\"value\")\n", + "slider_resolution = widgets.IntSlider(\n", + " value=1, min=1, max=10, step=1, description=\"resolution\"\n", + ")\n", + "slider_resolution.observe(update_resolution, names=\"value\")\n", + "widgets.HBox([slider_level, slider_resolution])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/jupyter/slice_explorer.ipynb b/examples/jupyter/example_slice_explorer.ipynb similarity index 50% rename from examples/jupyter/slice_explorer.ipynb rename to examples/jupyter/example_slice_explorer.ipynb index 6ca8f330..1271b097 100644 --- a/examples/jupyter/slice_explorer.ipynb +++ b/examples/jupyter/example_slice_explorer.ipynb @@ -1,29 +1,54 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "dabed9af-3420-430e-a893-a8a2d329b955", + "metadata": {}, + "source": [ + "### Imports" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "503a3444-159b-4f8e-a877-dda66d4922b4", + "id": "fc5f043e-00c4-4a04-8ea7-096ed987ea33", "metadata": {}, "outputs": [], "source": [ - "# Create an instance of a DatasetBuilder to initialize the Slice Explorer with\n", + "import json\n", + "from pathlib import Path\n", "\n", - "from pan3d import DatasetBuilder\n", - "builder = DatasetBuilder()" + "from pan3d.xarray.algorithm import vtkXArrayRectilinearSource\n", + "from pan3d.explorers.slicer import XArraySlicer" + ] + }, + { + "cell_type": "markdown", + "id": "1f7aa80c-b6e9-49cf-8cda-878238b7ff6b", + "metadata": {}, + "source": [ + "### Load some sample data" ] }, { "cell_type": "code", "execution_count": null, - "id": "fc5f043e-00c4-4a04-8ea7-096ed987ea33", + "id": "befdc2b5-eb26-4c5f-b2c6-34e09c9b5108", "metadata": {}, "outputs": [], "source": [ - "# Initialize the DatasetBuilder using an existing example configuration\n", + "config = json.loads(Path(\"../example_config_xarray.json\").read_text())\n", "\n", - "config_path = '../example_config_xarray.json'\n", - "builder.import_config(config_path)" + "source = vtkXArrayRectilinearSource()\n", + "source.load(config)" + ] + }, + { + "cell_type": "markdown", + "id": "d0e329ff-c183-4ba8-b38c-3e6122d1bbb4", + "metadata": {}, + "source": [ + "### Query the loaded data" ] }, { @@ -34,8 +59,17 @@ "outputs": [], "source": [ "# New API to query the spatial extents for the data\n", + "source.slice_extents" + ] + }, + { + "cell_type": "markdown", + "id": "2a540a5b-0d47-491c-87a1-284fe95c298a", + "metadata": {}, + "source": [ + "### Build slicer explorer\n", "\n", - "builder.extents" + "Use the existing data source." ] }, { @@ -45,10 +79,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Initialize the SliceExplorer using the configured DatasetBuilder\n", - "\n", - "from pan3d import SliceExplorer\n", - "explorer = SliceExplorer(builder)" + "explorer = XArraySlicer(source=source, server=\"esgf-slicer\")\n", + "await explorer.ui.ready" ] }, { @@ -58,12 +90,17 @@ "metadata": {}, "outputs": [], "source": [ - "# Wait for the UI to render and view the Trame UI \n", - "\n", - "await explorer.ui.ready\n", "explorer.ui" ] }, + { + "cell_type": "markdown", + "id": "2beeef90-7f0e-49e5-881f-808291ffa563", + "metadata": {}, + "source": [ + "## Interact with various properties of the XArraySlicer and watch the above Trame UI respond" + ] + }, { "cell_type": "code", "execution_count": null, @@ -71,9 +108,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Interact with various properties of the SliceExplorer and watch the above Trame UI respond\n", - "\n", - "explorer.slice_dimension = \"level\"" + "explorer.slice_axis = \"level\"" ] }, { @@ -83,7 +118,7 @@ "metadata": {}, "outputs": [], "source": [ - "explorer.slice_value = 800" + "explorer.slice_value = 350" ] }, { @@ -95,6 +130,24 @@ "source": [ "explorer.view_mode = \"2D\"" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7005e705-896f-4c73-85bd-2d5aeac17e5f", + "metadata": {}, + "outputs": [], + "source": [ + "explorer.slice_axis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e8462b6-c115-48f0-95c9-4b7ff7bab6be", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -113,7 +166,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/import_config_esgf.ipynb b/examples/jupyter/import_config_esgf.ipynb index e2c37324..d1cde440 100644 --- a/examples/jupyter/import_config_esgf.ipynb +++ b/examples/jupyter/import_config_esgf.ipynb @@ -7,7 +7,37 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder" + "import json\n", + "from pathlib import Path\n", + "\n", + "from pan3d.viewers.preview import XArrayViewer" + ] + }, + { + "cell_type": "markdown", + "id": "2c8e59f0-0f9c-40a4-9bc3-c0fff22d2be0", + "metadata": {}, + "source": [ + "## Create viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "edee9842-46f8-46cd-a0c1-5493ef2e3b4a", + "metadata": {}, + "outputs": [], + "source": [ + "viewer = XArrayViewer(server=\"esgf-viewer\")\n", + "await viewer.ui.ready" + ] + }, + { + "cell_type": "markdown", + "id": "84d23d39-2fad-4495-8701-b8fe69170639", + "metadata": {}, + "source": [ + "## Load dataset" ] }, { @@ -17,23 +47,34 @@ "metadata": {}, "outputs": [], "source": [ - "config_path = '../example_config_esgf.json'\n", - "builder = DatasetBuilder(catalogs=['esgf'])\n", - "geotrame = builder.viewer\n", - "\n", - "builder.import_config(config_path)\n", - "\n", - "# Show GeoTrame in cell output\n", - "await geotrame.ready\n", - "geotrame.ui\n" + "config = json.loads(Path(\"../example_config_esgf.json\").read_text())\n", + "viewer.import_state(config)" + ] + }, + { + "cell_type": "markdown", + "id": "4a15f7d2-faea-4aba-b4bc-653ac39e155f", + "metadata": {}, + "source": [ + "## Show UI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ea77430-d9ef-4feb-9ca3-99bf2725ad6f", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.ui" ] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -45,7 +86,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/import_config_pangeo.ipynb b/examples/jupyter/import_config_pangeo.ipynb index 29d0f5ef..c1446288 100644 --- a/examples/jupyter/import_config_pangeo.ipynb +++ b/examples/jupyter/import_config_pangeo.ipynb @@ -7,7 +7,48 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder" + "import json\n", + "from pathlib import Path\n", + "\n", + "from pan3d.viewers.preview import XArrayViewer" + ] + }, + { + "cell_type": "markdown", + "id": "7cf49ce5-411e-4ed3-965f-928c93f066ad", + "metadata": {}, + "source": [ + "## Create Viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5495d18-f311-4d1a-87ed-6c5bb988deb4", + "metadata": {}, + "outputs": [], + "source": [ + "viewer = XArrayViewer(server=\"pangeo-viewer\")\n", + "await viewer.ui.ready" + ] + }, + { + "cell_type": "markdown", + "id": "7d9d844d-09bd-4727-a006-a616c2c459a3", + "metadata": {}, + "source": [ + "## Load dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "951ccee5-dd3e-47e0-8218-0eaa12a0f9a8", + "metadata": {}, + "outputs": [], + "source": [ + "config = json.loads(Path(\"../example_config_pangeo.json\").read_text())\n", + "viewer.import_state(config)" ] }, { @@ -17,23 +58,23 @@ "metadata": {}, "outputs": [], "source": [ - "config_path = '../example_config_pangeo.json'\n", - "builder = DatasetBuilder(catalogs=['pangeo'])\n", - "geotrame = builder.viewer\n", - "\n", - "builder.import_config(config_path)\n", - "\n", - "# Show GeoTrame in cell output\n", - "await geotrame.ready\n", - "geotrame.ui\n" + "viewer.ui" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3efafa03-df99-4690-9ac9-2ed5920e7205", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -45,7 +86,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/import_config_xarray.ipynb b/examples/jupyter/import_config_xarray.ipynb index 5dd96449..96d3ac27 100644 --- a/examples/jupyter/import_config_xarray.ipynb +++ b/examples/jupyter/import_config_xarray.ipynb @@ -7,7 +7,32 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder" + "import json\n", + "from pathlib import Path\n", + "\n", + "from pan3d.viewers.preview import XArrayViewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3aed00e-4836-42d8-9a54-721f667aae9e", + "metadata": {}, + "outputs": [], + "source": [ + "viewer = XArrayViewer(server=\"local-viewer\")\n", + "await viewer.ui.ready" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89aee86f-bec4-4c8a-a67c-038acdf9404c", + "metadata": {}, + "outputs": [], + "source": [ + "config = json.loads(Path(\"../example_config_xarray.json\").read_text())\n", + "viewer.import_state(config)" ] }, { @@ -17,23 +42,23 @@ "metadata": {}, "outputs": [], "source": [ - "config_path = '../example_config_xarray.json'\n", - "builder = DatasetBuilder()\n", - "geotrame = builder.viewer\n", - "\n", - "builder.import_config(config_path)\n", - "\n", - "# Show GeoTrame in cell output\n", - "await geotrame.ready\n", - "geotrame.ui\n" + "viewer.ui" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7044305-0aa5-4ad0-aca3-c4f43f6e724a", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -45,7 +70,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/manual_config.ipynb b/examples/jupyter/manual_config.ipynb index c2d9c629..df06751f 100644 --- a/examples/jupyter/manual_config.ipynb +++ b/examples/jupyter/manual_config.ipynb @@ -7,7 +7,45 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder" + "import json\n", + "from pathlib import Path\n", + "\n", + "from pan3d.viewers.preview import XArrayViewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72f6ccbb-2afa-4854-ac45-209781653184", + "metadata": {}, + "outputs": [], + "source": [ + "viewer = XArrayViewer(server=\"manual-viewer\")\n", + "await viewer.ui.ready" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "726fbd8c-a570-4442-9826-1c7853029fc0", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.import_state(\n", + " {\n", + " \"data_origin\": {\n", + " \"source\": \"file\",\n", + " \"id\": \"../example_dataset.nc\",\n", + " },\n", + " \"dataset_config\": {\n", + " \"t_index\": 1,\n", + " \"arrays\": [\"density\"],\n", + " },\n", + " \"preview\": {\n", + " \"color_by\": \"density\",\n", + " },\n", + " }\n", + ")" ] }, { @@ -17,19 +55,7 @@ "metadata": {}, "outputs": [], "source": [ - "builder = DatasetBuilder(dataset='../example_dataset.nc')\n", - "geotrame = builder.viewer\n", - "\n", - "builder.data_array_name = 'density'\n", - "builder.x = 'length'\n", - "builder.y = 'width'\n", - "builder.z = 'height'\n", - "builder.t = 'second'\n", - "builder.t_index = 2\n", - "\n", - "# Show GeoTrame in cell output\n", - "await geotrame.ready\n", - "geotrame.ui" + "viewer.ui" ] }, { @@ -39,35 +65,45 @@ "metadata": {}, "outputs": [], "source": [ - "builder.dataset" + "print(f\"time: {viewer.source.t_index}/{viewer.source.t_size}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "e758296e-7ec4-433f-9106-55ebbc6d67c6", + "id": "5d616c00-2add-40c9-b66a-c0d5a72a505d", "metadata": {}, "outputs": [], "source": [ - "builder.data_array" + "data_extent = viewer.source.slice_extents\n", + "print(f\"{data_extent=}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "3fbe8d9f-4343-4ce7-b024-8bf38995b260", + "id": "b5e1f7dd-5c2f-4e15-9e1c-7ebef92d6c81", "metadata": {}, "outputs": [], "source": [ - "builder.mesh" + "vtk_mesh = viewer.source()\n", + "print(vtk_mesh)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89e640dc-dd78-46db-b69c-6f5f5fa73add", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -79,7 +115,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" }, "toc": { "base_numbering": 1, diff --git a/examples/jupyter/no_viewer.ipynb b/examples/jupyter/no_viewer.ipynb index f6306ea7..5cf35230 100644 --- a/examples/jupyter/no_viewer.ipynb +++ b/examples/jupyter/no_viewer.ipynb @@ -7,8 +7,12 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder\n", - "import pyvista" + "import json\n", + "import time\n", + "\n", + "import pyvista as pv\n", + "\n", + "from pan3d.xarray.algorithm import vtkXArrayRectilinearSource" ] }, { @@ -18,13 +22,19 @@ "metadata": {}, "outputs": [], "source": [ - "# If you cannot install trame and use GeoTrame,\n", - "# the Dataset Builder can be used alone to obtain data structures\n", - "# and a corresponding mesh for rendering with PyVista\n", - "\n", - "builder = DatasetBuilder(dataset='https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/gpcp-feedstock/gpcp.zarr')\n", - "builder.data_array_name = 'precip'\n", - "builder.t_index = 2" + "source = vtkXArrayRectilinearSource()\n", + "source.load(\n", + " {\n", + " \"data_origin\": {\n", + " \"source\": \"url\",\n", + " \"id\": \"https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/gpcp-feedstock/gpcp.zarr\",\n", + " },\n", + " \"dataset_config\": {\n", + " \"arrays\": [\"precip\"],\n", + " \"t_index\": 2,\n", + " },\n", + " }\n", + ")" ] }, { @@ -34,7 +44,8 @@ "metadata": {}, "outputs": [], "source": [ - "builder.dataset # Returns an xarray.Dataset" + "# Returns an xarray.Dataset\n", + "source.input" ] }, { @@ -44,19 +55,8 @@ "metadata": {}, "outputs": [], "source": [ - "builder.data_array # Returns an xarray.DataArray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fe94e67c-c1a5-4077-8bd0-fd87cce33e77", - "metadata": {}, - "outputs": [], - "source": [ - "# Accessing the mesh will take longer; we need to fetch all the data\n", - "mesh = builder.mesh # Returns a pyvista.Mesh\n", - "mesh" + "# Return vtkDataSet\n", + "source()" ] }, { @@ -66,7 +66,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Optional: mesh can be manipulated with PyVista methods\n", + "# Optional: convert to PyVista mesh\n", + "mesh = pv.wrap(source())\n", "mesh = mesh.warp_by_scalar()" ] }, @@ -78,18 +79,26 @@ "outputs": [], "source": [ "# Use PyVista Plotter to display mesh rendering\n", - "plotter = pyvista.Plotter()\n", + "plotter = pv.Plotter()\n", "plotter.add_mesh(mesh, cmap=\"jet\")\n", "plotter.view_isometric()\n", "plotter.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3df5c2b8-6e0b-41ca-bad7-d48c48970e32", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -101,7 +110,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/examples/jupyter/tutorial-xarray-viewer.ipynb b/examples/jupyter/tutorial-xarray-viewer.ipynb new file mode 100644 index 00000000..1c61bede --- /dev/null +++ b/examples/jupyter/tutorial-xarray-viewer.ipynb @@ -0,0 +1,161 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b9f418ad-518f-44d8-b6ee-bcf16d3d2821", + "metadata": {}, + "source": [ + "# Load viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ddad5069-f33f-4d15-a1f2-1f6404376ba0", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "from pan3d.viewers.preview import XArrayViewer\n", + "\n", + "viewer = XArrayViewer(server=\"wasm\", local_rendering=\"wasm\")\n", + "await viewer.ready" + ] + }, + { + "cell_type": "markdown", + "id": "253fb4dd-f6f5-4563-9349-7997fb5c2fb4", + "metadata": {}, + "source": [ + "## Load configuration from file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2fb79a96-0071-4b23-8bce-d4a502c4d2e9", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "config = Path(\"../example_config_xarray.json\")\n", + "if config.exists():\n", + " viewer.import_state(json.loads(config.read_text()))\n", + " print(\"State loaded\")\n", + "else:\n", + " print(f\"Could not find example state in {config.resolve()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "864c6c2d-086f-4710-86eb-cc736eea4aaf", + "metadata": {}, + "source": [ + "## Load inlined configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf9445d6-9cdd-4fd7-bfea-cb3cb149dc0c", + "metadata": {}, + "outputs": [], + "source": [ + "config = {\n", + " \"data_origin\": {\n", + " \"source\": \"url\",\n", + " \"id\": \"https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr\",\n", + " },\n", + " \"dataset_config\": {\n", + " \"arrays\": [\"analysed_sst\"],\n", + " \"slices\": {\"lon\": [1000, 6000, 20], \"lat\": [500, 3000, 20]},\n", + " },\n", + "}\n", + "viewer.import_state(config)" + ] + }, + { + "cell_type": "markdown", + "id": "852f0ffe-010a-40d0-ac0f-010f391ae795", + "metadata": { + "jupyter": { + "source_hidden": true + } + }, + "source": [ + "## Display viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d48a3b8-41e3-4ad9-a9a0-3d751fbf8ef8", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.ui" + ] + }, + { + "cell_type": "markdown", + "id": "97fd8d44-64c7-4ea6-84a6-ead2bc65ebed", + "metadata": {}, + "source": [ + "## Create PyVista viz\n", + "\n", + "And connect update with the Pan3D Viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a80f2992-f774-43e7-b4d7-8b825d67de02", + "metadata": {}, + "outputs": [], + "source": [ + "import pyvista as pv\n", + "\n", + "plotter = pv.Plotter()\n", + "plotter.show()\n", + "\n", + "xarray_reader = viewer.source\n", + "actor = plotter.add_mesh(xarray_reader, scalars=\"analysed_sst\", cmap=\"coolwarm\")\n", + "\n", + "# sync viewer update\n", + "viewer.ctrl.view_update.add(plotter.render)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dacecafa-97d1-429f-92e6-9ea82892398f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/jupyter/url_config.ipynb b/examples/jupyter/url_config.ipynb index a5783df1..768bb1f1 100644 --- a/examples/jupyter/url_config.ipynb +++ b/examples/jupyter/url_config.ipynb @@ -7,7 +7,10 @@ "metadata": {}, "outputs": [], "source": [ - "from pan3d import DatasetBuilder" + "from pan3d.viewers.preview import XArrayViewer\n", + "\n", + "viewer = XArrayViewer()\n", + "await viewer.ready" ] }, { @@ -17,34 +20,47 @@ "metadata": {}, "outputs": [], "source": [ - "config = {\n", - " 'data_origin': 'https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr',\n", - " 'data_array': {\n", - " 'name': 'analysed_sst',\n", - " 'x': 'lon',\n", - " 'y': 'lat',\n", - " 't': 'time',\n", - " },\n", - " 'data_slices': {\n", - " 'lon': [1000, 6000, 20],\n", - " 'lat': [500, 3000, 20],\n", - " },\n", - "}\n", - "builder = DatasetBuilder()\n", - "geotrame = builder.viewer\n", - "builder.import_config(config)\n", - "\n", - "# Show GeoTrame in cell output\n", - "await geotrame.ready\n", - "geotrame.ui" + "viewer.import_state(\n", + " {\n", + " \"data_origin\": {\n", + " \"source\": \"url\",\n", + " \"id\": \"https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr\",\n", + " },\n", + " \"dataset_config\": {\n", + " \"arrays\": [\"analysed_sst\"],\n", + " \"slices\": {\"lon\": [1000, 6000, 20], \"lat\": [500, 3000, 20]},\n", + " },\n", + " \"preview\": {\n", + " \"color_by\": \"analysed_sst\",\n", + " },\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fefeb80b-6403-4b23-98da-a745f67cdb16", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.ui" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "abc2b44a-e308-49d0-b925-dc774347174e", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pan3d", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "pan3d" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -56,7 +72,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.10.15" }, "toc": { "base_numbering": 1, diff --git a/examples/jupyter/viewer_state.ipynb b/examples/jupyter/viewer_state.ipynb deleted file mode 100644 index 651673e3..00000000 --- a/examples/jupyter/viewer_state.ipynb +++ /dev/null @@ -1,64 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "186ece10-fdd9-4976-a71b-b29da40e72ec", - "metadata": {}, - "outputs": [], - "source": [ - "from pan3d import DatasetViewer" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6fedc342-3b85-4892-a98a-d5c4ffe78ce8", - "metadata": {}, - "outputs": [], - "source": [ - "# GeoTrame can be instantiated without a Builder using the DatasetViewer class.\n", - "# The Builder will be created behind the scenes.\n", - "geotrame = DatasetViewer()\n", - "\n", - "# Use Xarray example: Regional Arctic System Model\n", - "geotrame.state.dataset_info = {\n", - " 'source': 'xarray',\n", - " 'id': 'rasm',\n", - "}\n", - "\n", - "# Apply change, allow for auto array/coordinate selection\n", - "geotrame.state.flush()\n", - "\n", - "# Make additional state modifications\n", - "geotrame.state.ui_main_drawer = False\n", - "geotrame.state.ui_axis_drawer = True\n", - "geotrame.state.ui_expanded_coordinates = ['time']\n", - "\n", - "await geotrame.ready\n", - "geotrame.ui" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "pan3d", - "language": "python", - "name": "pan3d" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.18" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/jupyter/xarray-pyvista.ipynb b/examples/jupyter/xarray-pyvista.ipynb new file mode 100644 index 00000000..f585e1cd --- /dev/null +++ b/examples/jupyter/xarray-pyvista.ipynb @@ -0,0 +1,61 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "cdc7fab3-4a34-44cc-9356-c7838855a4bf", + "metadata": {}, + "outputs": [], + "source": [ + "import pyvista as pv\n", + "from pan3d.xarray.algorithm import vtkXArrayRectilinearSource\n", + "\n", + "source = vtkXArrayRectilinearSource()\n", + "source.load({\"data_origin\": {\"source\": \"xarray\", \"id\": \"eraint_uvz\"}})\n", + "\n", + "plotter = pv.Plotter()\n", + "plotter.add_mesh(source, scalars=\"u\", cmap=\"coolwarm\")\n", + "plotter.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26047a78-1bc8-4c53-9d6e-76b6b1a3b53c", + "metadata": {}, + "outputs": [], + "source": [ + "print(source.state)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3aff71ad-0f3c-46a7-a996-46a41bffd304", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/slice_explorer.py b/examples/slice_explorer.py deleted file mode 100644 index d27f49d1..00000000 --- a/examples/slice_explorer.py +++ /dev/null @@ -1,26 +0,0 @@ -from argparse import ArgumentParser, BooleanOptionalAction - -from pan3d import DatasetBuilder -from pan3d import SliceExplorer - - -def serve(): - parser = ArgumentParser( - prog="Pan3D", - description="Launch the Pan3D GeoTrame App", - ) - - parser.add_argument("--config_path") - parser.add_argument("--server", action=BooleanOptionalAction) - parser.add_argument("--debug", action=BooleanOptionalAction) - args = parser.parse_args() - - builder = DatasetBuilder() - builder.import_config(args.config_path) - - viewer = SliceExplorer(builder) - viewer.start() - - -if __name__ == "__main__": - serve() diff --git a/pan3d-js/.eslintrc.cjs b/pan3d-js/.eslintrc.cjs deleted file mode 100644 index 425c253e..00000000 --- a/pan3d-js/.eslintrc.cjs +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-env node */ -module.exports = { - root: true, - extends: [ - "plugin:vue/vue3-essential", - "eslint:recommended", - "@vue/eslint-config-prettier", - ], - parserOptions: { - ecmaVersion: "latest", - }, -}; diff --git a/pan3d-js/.gitignore b/pan3d-js/.gitignore deleted file mode 100644 index 7dff8cea..00000000 --- a/pan3d-js/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -serve -serve/*.map -serve/*.html -serve/*.common.* -serve/*-light.* -serve/*.umd.js -package-lock.json diff --git a/pan3d-js/package-lock.json b/pan3d-js/package-lock.json deleted file mode 100644 index 535a708f..00000000 --- a/pan3d-js/package-lock.json +++ /dev/null @@ -1,2057 +0,0 @@ -{ - "name": "pan3d-components", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pan3d-components", - "version": "0.0.0", - "license": "MIT", - "devDependencies": { - "@vue/eslint-config-prettier": "^7.0.0", - "eslint": "^8.33.0", - "eslint-plugin-vue": "^9.3.0", - "prettier": "^2.7.1", - "vite": "^4.1.0", - "vue": "^3.0.0" - }, - "peerDependencies": { - "vue": "^2.7.0 || >=3.0.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vue/compiler-core": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.31.tgz", - "integrity": "sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.24.7", - "@vue/shared": "3.4.31", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz", - "integrity": "sha512-wK424WMXsG1IGMyDGyLqB+TbmEBFM78hIsOJ9QwUVLGrcSk0ak6zYty7Pj8ftm7nEtdU/DGQxAXp0/lM/2cEpQ==", - "dev": true, - "dependencies": { - "@vue/compiler-core": "3.4.31", - "@vue/shared": "3.4.31" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz", - "integrity": "sha512-einJxqEw8IIJxzmnxmJBuK2usI+lJonl53foq+9etB2HAzlPjAS/wa7r0uUpXw5ByX3/0uswVSrjNb17vJm1kQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.24.7", - "@vue/compiler-core": "3.4.31", - "@vue/compiler-dom": "3.4.31", - "@vue/compiler-ssr": "3.4.31", - "@vue/shared": "3.4.31", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.10", - "postcss": "^8.4.38", - "source-map-js": "^1.2.0" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.31.tgz", - "integrity": "sha512-RtefmITAje3fJ8FSg1gwgDhdKhZVntIVbwupdyZDSifZTRMiWxWehAOTCc8/KZDnBOcYQ4/9VWxsTbd3wT0hAA==", - "dev": true, - "dependencies": { - "@vue/compiler-dom": "3.4.31", - "@vue/shared": "3.4.31" - } - }, - "node_modules/@vue/eslint-config-prettier": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz", - "integrity": "sha512-Pv/lVr0bAzSIHLd9iz0KnvAr4GKyCEl+h52bc4e5yWuDVtLgFwycF7nrbWTAQAS+FU6q1geVd07lc6EWfJiWKQ==", - "dev": true, - "dependencies": { - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-prettier": "^4.0.0" - }, - "peerDependencies": { - "eslint": ">= 7.28.0", - "prettier": ">= 2.0.0" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz", - "integrity": "sha512-VGkTani8SOoVkZNds1PfJ/T1SlAIOf8E58PGAhIOUDYPC4GAmFA2u/E14TDAFcf3vVDKunc4QqCe/SHr8xC65Q==", - "dev": true, - "dependencies": { - "@vue/shared": "3.4.31" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.31.tgz", - "integrity": "sha512-LDkztxeUPazxG/p8c5JDDKPfkCDBkkiNLVNf7XZIUnJ+66GVGkP+TIh34+8LtPisZ+HMWl2zqhIw0xN5MwU1cw==", - "dev": true, - "dependencies": { - "@vue/reactivity": "3.4.31", - "@vue/shared": "3.4.31" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz", - "integrity": "sha512-2Auws3mB7+lHhTFCg8E9ZWopA6Q6L455EcU7bzcQ4x6Dn4cCPuqj6S2oBZgN2a8vJRS/LSYYxwFFq2Hlx3Fsaw==", - "dev": true, - "dependencies": { - "@vue/reactivity": "3.4.31", - "@vue/runtime-core": "3.4.31", - "@vue/shared": "3.4.31", - "csstype": "^3.1.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.31.tgz", - "integrity": "sha512-D5BLbdvrlR9PE3by9GaUp1gQXlCNadIZytMIb8H2h3FMWJd4oUfkUTEH2wAr3qxoRz25uxbTcbqd3WKlm9EHQA==", - "dev": true, - "dependencies": { - "@vue/compiler-ssr": "3.4.31", - "@vue/shared": "3.4.31" - }, - "peerDependencies": { - "vue": "3.4.31" - } - }, - "node_modules/@vue/shared": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.31.tgz", - "integrity": "sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-prettier": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", - "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "eslint": ">=7.28.0", - "prettier": ">=2.0.0" - }, - "peerDependenciesMeta": { - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-vue": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.26.0.tgz", - "integrity": "sha512-eTvlxXgd4ijE1cdur850G6KalZqk65k1JKoOI2d1kT3hr8sPD07j1q98FRFdNnpxBELGPWxZmInxeHGF/GxtqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.0", - "vue-eslint-parser": "^9.4.2", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", - "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", - "dev": true, - "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.4.31", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.31.tgz", - "integrity": "sha512-njqRrOy7W3YLAlVqSKpBebtZpDVg21FPoaq1I7f/+qqBThK9ChAIjkRWgeP6Eat+8C+iia4P3OYqpATP21BCoQ==", - "dev": true, - "dependencies": { - "@vue/compiler-dom": "3.4.31", - "@vue/compiler-sfc": "3.4.31", - "@vue/runtime-dom": "3.4.31", - "@vue/server-renderer": "3.4.31", - "@vue/shared": "3.4.31" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/pan3d-js/package.json b/pan3d-js/package.json deleted file mode 100644 index 76ced23b..00000000 --- a/pan3d-js/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "pan3d-components", - "version": "0.0.0", - "description": "Vue component for pan3d", - "main": "./dist/pan3d-components.umd.js", - "unpkg": "./dist/pan3d-components.umd.js", - "exports": { - ".": { - "require": "./dist/pan3d-components.umd.js" - } - }, - "scripts": { - "dev": "vite", - "build": "vite build --emptyOutDir", - "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore --ignore-pattern public", - "semantic-release": "semantic-release" - }, - "dependencies": {}, - "peerDependencies": { - "vue": "^2.7.0 || >=3.0.0" - }, - "devDependencies": { - "@vue/eslint-config-prettier": "^7.0.0", - "eslint": "^8.33.0", - "eslint-plugin-vue": "^9.3.0", - "prettier": "^2.7.1", - "vite": "^4.1.0", - "vue": "^3.0.0" - }, - "author": "Kitware Inc", - "license": "MIT", - "keywords": [ - "Kitware", - "pan3d" - ], - "files": [ - "dist/*", - "src/*", - "*.json", - "*.js" - ] -} diff --git a/pan3d-js/src/components/PreviewBounds.js b/pan3d-js/src/components/PreviewBounds.js deleted file mode 100644 index 40245ab4..00000000 --- a/pan3d-js/src/components/PreviewBounds.js +++ /dev/null @@ -1,280 +0,0 @@ -const { ref, toRefs, watch, onMounted } = window.Vue; - -export default { - props: { - preview: { - default: null, - }, - axes: { - default: null, - }, - coordinates: { - default: null, - }, - }, - emits: ["update-bounds"], - setup(props, { emit }) { - const svg = ref(); - const { preview, coordinates } = toRefs(props); - const previewImage = ref(); - const previewShape = ref(); - const viewBox = ref(); - const boundsBox = ref([0, 0, 0, 0]); - let dragging = null; - - function updateViewBox() { - const i = new Image(); - i.onload = () => { - const factor = 300 / i.width; - previewShape.value = [300, Math.round(i.height * factor)]; - viewBox.value = `0 0 ${previewShape.value.join(" ")}`; - }; - i.src = preview.value; - } - - function updateBoundsBox() { - if (!previewShape.value) return; - let xMin; - let xMax; - let yMin; - let yMax; - props.coordinates.forEach((coord) => { - if (coord.name === props.axes.x) { - let min = - ((coord.bounds[0] - coord.full_bounds[0]) / - (coord.full_bounds[1] - coord.full_bounds[0])) * - previewShape.value[0]; - let max = - ((coord.bounds[1] - coord.full_bounds[0]) / - (coord.full_bounds[1] - coord.full_bounds[0])) * - previewShape.value[0]; - if (coord.reverse_order === "True") { - xMin = previewShape.value[0] - max; - xMax = previewShape.value[0] - min; - } else { - xMin = min; - xMax = max; - } - } else if (coord.name === props.axes.y) { - let min = - ((coord.bounds[0] - coord.full_bounds[0]) / - (coord.full_bounds[1] - coord.full_bounds[0])) * - previewShape.value[1]; - let max = - ((coord.bounds[1] - coord.full_bounds[0]) / - (coord.full_bounds[1] - coord.full_bounds[0])) * - previewShape.value[1]; - // Y is reversed by default - if (coord.reverse_order !== "True") { - yMin = previewShape.value[1] - max; - yMax = previewShape.value[1] - min; - } else { - yMin = min; - yMax = max; - } - } - }); - boundsBox.value = [xMin, yMin, xMax, yMax]; - } - - function onMousePress(e) { - const allowance = 5; - const svgBounds = svg.value.getBoundingClientRect(); - const location = [e.clientX - svgBounds.x, e.clientY - svgBounds.y]; - if ( - location[0] >= boundsBox.value[0] - allowance && - location[0] <= boundsBox.value[2] + allowance && - location[1] >= boundsBox.value[1] - allowance && - location[1] <= boundsBox.value[3] + allowance - ) { - dragging = { - from: location, - xMin: false, - xMax: false, - yMin: false, - yMax: false, - wholeBox: true, - }; - if (Math.abs(location[0] - boundsBox.value[0]) <= allowance * 2) { - dragging.xMin = true; - dragging.wholeBox = false; - } - if (Math.abs(location[0] - boundsBox.value[2]) <= allowance * 2) { - dragging.xMax = true; - dragging.wholeBox = false; - } - if (Math.abs(location[1] - boundsBox.value[1]) <= allowance * 2) { - dragging.yMin = true; - dragging.wholeBox = false; - } - if (Math.abs(location[1] - boundsBox.value[3]) <= allowance * 2) { - dragging.yMax = true; - dragging.wholeBox = false; - } - } - } - - function onMouseMove(e) { - if (dragging) { - const svgBounds = svg.value.getBoundingClientRect(); - const location = [e.clientX - svgBounds.x, e.clientY - svgBounds.y]; - const [dx, dy] = [ - location[0] - dragging.from[0], - location[1] - dragging.from[1], - ]; - dragging.from = location; - let xMin = boundsBox.value[0]; - let yMin = boundsBox.value[1]; - let xMax = boundsBox.value[2]; - let yMax = boundsBox.value[3]; - if (dragging.xMin || dragging.xMax || dragging.wholeBox) { - if (dragging.xMin || dragging.wholeBox) xMin += dx; - if (dragging.xMax || dragging.wholeBox) xMax += dx; - if (xMin >= 0 && xMax <= previewShape.value[0]) { - boundsBox.value[0] = xMin; - boundsBox.value[2] = xMax; - } - } - if (dragging.yMin || dragging.yMax || dragging.wholeBox) { - if (dragging.yMin || dragging.wholeBox) yMin += dy; - if (dragging.yMax || dragging.wholeBox) yMax += dy; - if (yMin >= 0 && yMax <= previewShape.value[1]) { - boundsBox.value[1] = yMin; - boundsBox.value[3] = yMax; - } - } - } - } - - function onMouseRelease() { - if (dragging) { - dragging = null; - let xRange; - let yRange; - props.coordinates.forEach((coord) => { - var coordRange = coord.full_bounds[1] - coord.full_bounds[0]; - if (coord.name === props.axes.x) { - let xMin = boundsBox.value[0]; - let xMax = boundsBox.value[2]; - if (coord.reverse_order === "True") { - xMin = previewShape.value[0] - boundsBox.value[2]; - xMax = previewShape.value[0] - boundsBox.value[0]; - } - xRange = [ - (xMin / previewShape.value[0]) * coordRange + coord.full_bounds[0], - (xMax / previewShape.value[0]) * coordRange + coord.full_bounds[0], - ]; - } else if (coord.name === props.axes.y) { - let yMin = boundsBox.value[1]; - let yMax = boundsBox.value[3]; - // Y is reversed by default - if (coord.reverse_order !== "True") { - yMin = previewShape.value[1] - boundsBox.value[3]; - yMax = previewShape.value[1] - boundsBox.value[1]; - } - yRange = [ - (yMin / previewShape.value[1]) * coordRange + coord.full_bounds[0], - (yMax / previewShape.value[1]) * coordRange + coord.full_bounds[0], - ]; - } - }); - emit("update-bounds", { - name: props.axes.x, - bounds: xRange.map((v) => Math.round(v)), - }); - emit("update-bounds", { - name: props.axes.y, - bounds: yRange.map((v) => Math.round(v)), - }); - } - } - - onMounted(updateViewBox); - watch(preview, updateViewBox); - watch(previewShape, updateBoundsBox, { deep: true }); - watch(coordinates, updateBoundsBox, { deep: true }); - - return { - svg, - preview, - previewImage, - viewBox, - boundsBox, - onMouseMove, - onMousePress, - onMouseRelease, - color: "rgb(255, 0, 0)", - outline: "4px solid rgb(0, 100, 255)", - radius: 7, - }; - }, - template: ` - - - - - - - - - - - -`, -}; diff --git a/pan3d-js/src/components/index.js b/pan3d-js/src/components/index.js deleted file mode 100644 index 4bd00c06..00000000 --- a/pan3d-js/src/components/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import PreviewBounds from "./PreviewBounds"; - -export default { - PreviewBounds, -}; diff --git a/pan3d-js/src/main.js b/pan3d-js/src/main.js deleted file mode 100644 index 55d953f7..00000000 --- a/pan3d-js/src/main.js +++ /dev/null @@ -1,7 +0,0 @@ -import components from "./components"; - -export function install(Vue) { - Object.keys(components).forEach((name) => { - Vue.component(name, components[name]); - }); -} diff --git a/pan3d-js/vite.config.js b/pan3d-js/vite.config.js deleted file mode 100644 index b3aa06b9..00000000 --- a/pan3d-js/vite.config.js +++ /dev/null @@ -1,21 +0,0 @@ -export default { - base: "./", - build: { - lib: { - entry: "./src/main.js", - name: "pan3d_components", - formats: ["umd"], - fileName: "pan3d-components", - }, - rollupOptions: { - external: ["vue"], - output: { - globals: { - vue: "Vue", - }, - }, - }, - outDir: "../pan3d/ui/pan3d_components/module/serve", - assetsDir: ".", - }, -}; diff --git a/pan3d/__init__.py b/pan3d/__init__.py index d78ba6fa..f8486a33 100644 --- a/pan3d/__init__.py +++ b/pan3d/__init__.py @@ -1,22 +1,11 @@ import logging -from pan3d.dataset_builder import DatasetBuilder -from contextlib import suppress - logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) __version__ = "0.9.2" -__all__ = ["DatasetBuilder"] - -with suppress(ImportError): - from pan3d.dataset_viewer import DatasetViewer # noqa - - __all__.append("DatasetViewer") - - -with suppress(ImportError): - from pan3d.explorers.slice_explorer import SliceExplorer # noqa - - __all__.append("SliceExplorer") +__all__ = [ + "logger", + "__version__", +] diff --git a/pan3d/catalogs/__init__.py b/pan3d/catalogs/__init__.py index 1d07bac9..a29e5e81 100644 --- a/pan3d/catalogs/__init__.py +++ b/pan3d/catalogs/__init__.py @@ -30,9 +30,33 @@ def load_dataset(catalog_name, id): return _call_catalog_function(catalog_name, "load_dataset", id=id) +def list_availables(): + # FIXME - make it dynamic + return [ + {"value": "file", "title": "Local file"}, + {"value": "url", "title": "Remote URL"}, + {"value": "xarray", "title": "XArray Tutorial"}, + # {"value": "esgf", "title": "ESGF"}, + # {"value": "pangeo", "title": "Pangeo"}, + ] + + +def list_availables_search(): + # FIXME do a try import and if empty suggest pip install + return [ + {"id": "esgf", "name": "ESGF", "search_terms": [{"key": "id", "options": []}]}, + { + "id": "pangeo", + "name": "Pangeo Forge", + "search_terms": [{"key": "id", "options": []}], + }, + ] + + __all__ = [ get, get_search_options, search, load_dataset, + list_availables, ] diff --git a/pan3d/catalogs/esgf.py b/pan3d/catalogs/esgf.py index 7df62a45..10bf086a 100644 --- a/pan3d/catalogs/esgf.py +++ b/pan3d/catalogs/esgf.py @@ -32,7 +32,11 @@ def search(**kwargs): try: search = catalog.search(**kwargs) results = [ - {"name": id, "value": {"source": "esgf", "id": id}} + { + "id": id, + "subtitle": id, + "value": {"source": "esgf", "id": id}, + } for id in search.df.id.explode().unique() ] except NoSearchResults: diff --git a/pan3d/catalogs/file.py b/pan3d/catalogs/file.py new file mode 100644 index 00000000..46fd55ef --- /dev/null +++ b/pan3d/catalogs/file.py @@ -0,0 +1,33 @@ +import xarray +from pathlib import Path + + +def get_catalog(): + return { + "name": "Local datasets", + "id": "file", + "search_terms": [{"key": "id", "options": []}], + } + + +def get_search_options(): + return {"file": []} + + +def search(**kwargs): + results = [] + group_name = "Local datasets" + message = "Sorry but we don't have any search mechanism for that data source" + return (results, group_name, message) + + +def load_dataset(id): + if not Path(id).exists(): + raise ValueError(f"Could not find dataset at {id}") + + engine = None + if ".zarr" in id: + engine = "zarr" + if ".nc" in id: + engine = "netcdf4" + return xarray.open_dataset(id, engine=engine, chunks={}) diff --git a/pan3d/catalogs/pangeo.py b/pan3d/catalogs/pangeo.py index 6a4296be..5aec1802 100644 --- a/pan3d/catalogs/pangeo.py +++ b/pan3d/catalogs/pangeo.py @@ -101,8 +101,8 @@ def search(**kwargs): all_entries = get_all_entries() results = [ { - "name": entry["name"], - "description": entry["description"], + "id": entry["name"], + "subtitle": entry["description"], "value": {"source": "pangeo", "id": entry["name"]}, } for entry in all_entries diff --git a/pan3d/catalogs/url.py b/pan3d/catalogs/url.py new file mode 100644 index 00000000..424b9f39 --- /dev/null +++ b/pan3d/catalogs/url.py @@ -0,0 +1,29 @@ +import xarray + + +def get_catalog(): + return { + "name": "Remote URL datasets", + "id": "url", + "search_terms": [{"key": "id", "options": []}], + } + + +def get_search_options(): + return {"url": []} + + +def search(**kwargs): + results = [] + group_name = "Remote URL datasets" + message = "Sorry but we don't have any search mechanism for that data source" + return (results, group_name, message) + + +def load_dataset(id): + engine = None + if ".zarr" in id: + engine = "zarr" + if ".nc" in id: + engine = "netcdf4" + return xarray.open_dataset(id, engine=engine, chunks={}) diff --git a/pan3d/catalogs/xarray.py b/pan3d/catalogs/xarray.py new file mode 100644 index 00000000..ece97c5d --- /dev/null +++ b/pan3d/catalogs/xarray.py @@ -0,0 +1,93 @@ +from xarray.tutorial import open_dataset + +ALL_ENTRIES = [ + { + "name": "air_temperature", + "description": "NCEP reanalysis subset", + }, + { + "name": "air_temperature_gradient", + "description": "NCEP reanalysis subset with approximate x,y gradients", + }, + { + "name": "basin_mask", + "description": "Dataset with ocean basins marked using integers", + }, + # ------------------------------------------------------------------------- + # { + # "name": "ASE_ice_velocity", + # "description": "MEaSUREs InSAR-Based Ice Velocity of the Amundsen Sea Embayment, Antarctica, Version 1", + # }, + # ------------------------------------------------------------------------- + # { + # "name": "rasm", + # "description": "Output of the Regional Arctic System Model (RASM)", + # }, + # ------------------------------------------------------------------------- + # { + # "name": "ROMS_example", + # "description": "Regional Ocean Model System (ROMS) output", + # }, + # ------------------------------------------------------------------------- + # { + # "name": "tiny", + # "description": "small synthetic dataset with a 1D data variable", + # }, + # ------------------------------------------------------------------------- + # needs pandas[xarray] + # { + # "name": "era5-2mt-2019-03-uk.grib", + # "description": "ERA5 temperature data over the UK", + # }, + # ------------------------------------------------------------------------- + { + "name": "eraint_uvz", + "description": "data from ERA-Interim reanalysis, monthly averages of upper level data", + }, + { + "name": "ersstv5", + "description": "NOAA’s Extended Reconstructed Sea Surface Temperature monthly averages", + }, +] + + +def get_catalog(): + return { + "name": "Xarray Tutorial", + "id": "xarray", + "search_terms": [{"key": "id", "options": []}], + } + + +def get_search_options(): + search_options = { + "name": [], + } + for entry_info in ALL_ENTRIES: + for search_option in search_options.keys(): + entry_value = entry_info.get(search_option) + if entry_value: + if isinstance(entry_value, str): + entry_value = [entry_value] + search_options[search_option] = list( + set([*search_options[search_option], *entry_value]) + ) + + return search_options + + +def to_result(entry): + return {**entry, "value": {"source": "xarray", "id": entry["name"]}} + + +def search(**kwargs): + results = [to_result(entry) for entry in ALL_ENTRIES] + group_name = "Xarray Tutorial" + message = ( + f'Found {len(ALL_ENTRIES)} dataset ids. Results added to group "{group_name}".' + ) + return (results, group_name, message) + + +def load_dataset(id): + return open_dataset(id) diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py deleted file mode 100644 index 4bc3543f..00000000 --- a/pan3d/dataset_builder.py +++ /dev/null @@ -1,627 +0,0 @@ -import os -import math -import json -import pyvista -import xarray - -from pan3d.utils.constants import coordinate_auto_selection -from pan3d import catalogs as pan3d_catalogs -from pathlib import Path -from pvxarray.vtk_source import PyVistaXarraySource -from typing import Any, Dict, List, Optional, Union, Tuple - - -class DatasetBuilder: - """Manage data structure, slicing, and mesh creation for a target N-D dataset.""" - - def __init__( - self, - dataset: str = None, - server: Any = None, - viewer: bool = False, - catalogs: List[str] = [], - resolution: int = None, - ) -> None: - """Create an instance of the DatasetBuilder class. - - Parameters: - dataset: A path or URL referencing a dataset readable by xarray.open_dataset() - server: Trame server name or instance. - catalogs: A list of strings referencing available catalog modules (options include 'pangeo', 'esgf'). Each included catalog will be available to search in the GeoTrame UI. - """ - self._algorithm = PyVistaXarraySource() - self._viewer = None - self._dataset = None - self._dataset_info = None - self._da_name = None - self._resolution = resolution or 2**7 - self._import_mode = False - self._import_viewer_state = {} - - self._server = server - self._catalogs = catalogs - - if viewer: - # Access to instantiate - self.viewer - - if dataset: - self.dataset_info = { - "source": "default", - "id": dataset, - } - - # ----------------------------------------------------- - # Properties - # ----------------------------------------------------- - - @property - def viewer(self): - """Return the GeoTrame instance for this DatasetBuilder. - If none exists, create a new one and synchronize state. - """ - from pan3d.dataset_viewer import DatasetViewer - - if self._viewer is None: - self._viewer = DatasetViewer( - builder=self, - server=self._server, - catalogs=self._catalogs, - state=dict( - dataset_info=self.dataset_info, - da_active=self.data_array_name, - da_x=self.x, - da_y=self.y, - da_z=self.z, - da_t=self.t, - da_t_index=self.t_index, - da_auto_slicing=self._resolution > 1, - **self._import_viewer_state, - ), - ) - return self._viewer - - @property - def dataset_info(self) -> Optional[Dict]: - """A dictionary referencing the current dataset. - This dictionary should adhere to the following schema: - - | Key | Required? | Default | Type | Value Description | - |-----|-----------|---------|------|-------------------| - | `id` | Yes | | string | A unique identifier that will be used to load the dataset - |`source`| No | "default" | string | Name of a module to load the dataset (options: "default", "xarray", "pangeo", "esgf") - - With the default source, the id value must be readable with xarray.open_dataset(). - """ - return self._dataset_info - - @dataset_info.setter - def dataset_info(self, dataset_info: Optional[Dict]) -> None: - if dataset_info is not None: - if not isinstance(dataset_info, dict): - raise TypeError("Type of dataset_info must be Dict or None.") - source = dataset_info.get("source") - id = dataset_info.get("id") - if not isinstance(id, str): - raise ValueError( - 'Dataset info must contain key "id" with string value.' - ) - if source is None: - dataset_info["source"] = "default" - elif source not in ["default", "xarray", "pangeo", "esgf"]: - raise ValueError( - "Invalid source value. Must be one of [default, xarray, pangeo, esgf]." - ) - if dataset_info != self._dataset_info: - self._dataset_info = dataset_info - self._set_state_values(dataset_info=dataset_info) - self._load_dataset(dataset_info) - - @property - def dataset(self) -> Optional[xarray.Dataset]: - """Xarray.Dataset object read from the current dataset_info.""" - return self._dataset - - @dataset.setter - def dataset(self, dataset: Optional[xarray.Dataset]) -> None: - if dataset is not None and not isinstance(dataset, xarray.Dataset): - raise TypeError("Type of dataset must be xarray.Dataset or None.") - self._dataset = dataset - if dataset is not None: - vars = list( - k - for k in dataset.data_vars.keys() - if not k.endswith("_bnds") and not k.endswith("_bounds") - ) - if len(vars) > 0: - self.data_array_name = vars[0] - else: - self.data_array_name = None - if self._viewer: - self._viewer._dataset_changed() - self._viewer._mesh_changed() - - @property - def data_array_name(self) -> Optional[str]: - """String name of an array that exists on the current dataset.""" - return self._da_name - - @data_array_name.setter - def data_array_name(self, data_array_name: Optional[str]) -> None: - if data_array_name is not None: - if not isinstance(data_array_name, str): - raise TypeError("Type of data_array_name must be str or None.") - if self.dataset is None: - raise ValueError( - "Cannot set data array name without setting dataset info first." - ) - if data_array_name not in self.dataset.data_vars: - acceptable_values = list(self.dataset.data_vars.keys()) - raise ValueError( - f"{data_array_name} does not exist on dataset. Must be one of {acceptable_values}." - ) - if data_array_name != self._da_name: - self._da_name = data_array_name - self._set_state_values(da_active=data_array_name) - da = None - self.x = None - self.y = None - self.z = None - self.t = None - self.t_index = 0 - if data_array_name is not None and self.dataset is not None: - da = self.dataset[data_array_name] - if len(da.indexes.variables.mapping) == 0: - da = da.assign_coords({d: range(s) for d, s in da.sizes.items()}) - self._algorithm.data_array = da - if self._viewer: - self._viewer._data_array_changed() - self._viewer._mesh_changed() - if not self._import_mode: - self._auto_select_coordinates() - self._auto_select_slicing() - - @property - def data_array(self) -> Optional[xarray.DataArray]: - """Return the current Xarray data array with current slicing applied.""" - return self._algorithm.sliced_data_array - - @property - def data_range(self) -> Tuple[Any]: - """Return the minimum and maximum of the current Xarray data array with current slicing applied.""" - if self.dataset is None: - return None - return self._algorithm.data_range - - @property - def x(self) -> Optional[str]: - """String name of a coordinate that should be rendered on the X axis. - Value must exist in coordinates of current data array.""" - return self._algorithm.x - - @x.setter - def x(self, x: Optional[str]) -> None: - if x is not None: - if not isinstance(x, str): - raise TypeError("Type of x must be str or None.") - if self.data_array_name is None: - raise ValueError("Cannot set x without setting data array name first.") - acceptable_values = self.dataset[self.data_array_name].dims - if x not in acceptable_values: - raise ValueError( - f"{x} does not exist on data array. Must be one of {sorted(acceptable_values)}." - ) - if self._algorithm.x != x: - self._algorithm.x = x - self._set_state_values(da_x=x) - if self._viewer: - self._viewer._mesh_changed() - - @property - def y(self) -> Optional[str]: - """String name of a coordinate that should be rendered on the Y axis. - Value must exist in coordinates of current data array.""" - return self._algorithm.y - - @y.setter - def y(self, y: Optional[str]) -> None: - if y is not None: - if not isinstance(y, str): - raise TypeError("Type of y must be str or None.") - if self.data_array_name is None: - raise ValueError("Cannot set y without setting data array name first.") - acceptable_values = self.dataset[self.data_array_name].dims - if y not in acceptable_values: - raise ValueError( - f"{y} does not exist on data array. Must be one of {sorted(acceptable_values)}." - ) - if self._algorithm.y != y: - self._algorithm.y = y - self._set_state_values(da_y=y) - if self._viewer: - self._viewer._mesh_changed() - - @property - def z(self) -> Optional[str]: - """String name of a coordinate that should be rendered on the Z axis. - Value must exist in coordinates of current data array.""" - return self._algorithm.z - - @z.setter - def z(self, z: Optional[str]) -> None: - if z is not None: - if not isinstance(z, str): - raise TypeError("Type of z must be str or None.") - if self.data_array_name is None: - raise ValueError("Cannot set z without setting data array name first.") - acceptable_values = self.dataset[self.data_array_name].dims - if z not in acceptable_values: - raise ValueError( - f"{z} does not exist on data array. Must be one of {sorted(acceptable_values)}." - ) - if self._algorithm.z != z: - self._algorithm.z = z - self._set_state_values(da_z=z) - if self._viewer: - self._viewer._mesh_changed() - - @property - def t(self) -> Optional[str]: - """String name of a coordinate that represents time or some other fourth dimension. - Only one slice may be viewed at once. - Value must exist in coordinates of current data array.""" - return self._algorithm.time - - @t.setter - def t(self, t: Optional[str]) -> None: - if t is not None: - if not isinstance(t, str): - raise TypeError("Type of t must be str or None.") - if self.data_array_name is None: - raise ValueError("Cannot set t without setting data array name first.") - acceptable_values = self.dataset[self.data_array_name].dims - if t not in acceptable_values: - raise ValueError( - f"{t} does not exist on data array. Must be one of {sorted(acceptable_values)}." - ) - if self._algorithm.time != t: - self._algorithm.time = t - self._set_state_values(da_t=t) - if self._viewer: - self._viewer._time_index_changed() - self._viewer._mesh_changed() - - @property - def t_index(self) -> int: - """Integer representing the index of the current time slice.""" - return self._algorithm.time_index - - @t_index.setter - def t_index(self, t_index: int) -> None: - if not isinstance(t_index, int): - raise TypeError("Type of t_index must be int.") - if t_index < 0: - raise ValueError("Time index must be a positive integer.") - if t_index > 0: - if not self.t: - raise ValueError( - "Cannot set time index > 0 without setting t array first." - ) - max_value = self.dataset[self.data_array_name].coords[self.t].size - if t_index >= max_value: - raise ValueError( - f"Time index must be less than size of t coordinate ({max_value})." - ) - if self._algorithm.time_index != t_index: - self._algorithm.time_index = int(t_index) - self._set_state_values(da_t_index=t_index) - if self._viewer: - self._viewer._time_index_changed() - self._viewer._mesh_changed() - - @property - def t_size(self) -> int: - """Returns the number of time slices available""" - if not self.t: - raise ValueError("Cannot set time index > 0 without setting t array first.") - t_coords = self.dataset[self.data_array_name].coords[self.t] - return t_coords.size - - @property - def t_range(self) -> Tuple[Any]: - """Returns the min and max values for the temporal coordinate""" - if not self.t: - raise ValueError("Cannot set time index > 0 without setting t array first.") - t_coords = self.dataset[self.data_array_name].coords[self.t].to_numpy().tolist() - return (t_coords[0], t_coords[-1]) - - @property - def t_values(self) -> List: - """Returns the values for the temporal dimension""" - if not self.t: - raise ValueError("Cannot set time index > 0 without setting t array first.") - t_coords = self.dataset[self.data_array_name].coords[self.t] - return list(t_coords.values) - - @property - def var_ranges(self) -> map: - """Returns a map with variable names as keys and their ranges as values""" - range_map = {} - for var in self.dataset.data_vars: - arr = self.dataset[var].to_numpy() - range_map[var] = (arr.min(), arr.max()) - return range_map - - @property - def slicing(self) -> Dict[str, List]: - """Dictionary mapping of coordinate names to slice arrays. - Each key should exist in the coordinates of the current data array. - Each value should be an array consisting of three - integers or floats representing start value, stop value, and step. - """ - return self._algorithm.slicing - - @property - def extents(self) -> map: - """ - Returns a map with dimension name as keys and their range (extents) - as values - """ - extents = {} - dims = [self.x, self.y, self.z] - for i, dim in enumerate(dims): - if dim: - coords = self.dataset.coords[dim].to_numpy() - extents[dim] = (coords.min(), coords.max()) - return extents - - @slicing.setter - def slicing(self, slicing: Dict[str, List]) -> None: - if slicing is not None: - if not isinstance(slicing, Dict): - raise TypeError("Type of slicing must be Dict or None.") - if self.data_array_name is None: - raise ValueError( - "Cannot set slicing without setting data array name first." - ) - for key, value in slicing.items(): - if not isinstance(key, str): - raise ValueError("Keys in slicing must be strings.") - if ( - not isinstance(value, list) - or len(value) != 3 - or any(not isinstance(v, int) for v in value) - ): - raise ValueError( - "Values in slicing must be lists of 3 integers ([start, stop, step])." - ) - da = self.dataset[self.data_array_name] - acceptable_coords = da.dims - if key not in acceptable_coords: - raise ValueError( - f"Key {key} not found in data array. Must be one of {sorted(acceptable_coords)}." - ) - key_coord = da[key] - - step = value[2] - if step > key_coord.size: - raise ValueError( - f"Value {value} not applicable for Key {key}. Step value must be <= {key_coord.size}." - ) - - self._algorithm.slicing = slicing - if self._viewer: - self._viewer._data_slicing_changed() - self._viewer._mesh_changed() - - @property - def mesh( - self, - ) -> Union[pyvista.core.grid.RectilinearGrid, pyvista.StructuredGrid]: - """Returns the PyVista Mesh derived from the current data array.""" - if self.data_array is None: - return None - return self._algorithm.mesh - - # ----------------------------------------------------- - # Internal methods - # ----------------------------------------------------- - - def _load_dataset(self, dataset_info): - ds = None - if dataset_info is not None: - source = dataset_info.get("source") - if source in ["pangeo", "esgf"]: - ds = pan3d_catalogs.load_dataset(source, id=dataset_info["id"]) - elif source == "xarray": - ds = xarray.tutorial.load_dataset(dataset_info["id"]) - else: - ds = self._load_dataset_default(dataset_info) - - if ds is not None: - self.dataset = ds - - def _load_dataset_default(self, dataset_info): - # Assume 'id' in dataset_info is a path or url - if "https://" in dataset_info["id"] or os.path.exists(dataset_info["id"]): - engine = None - if ".zarr" in dataset_info["id"]: - engine = "zarr" - if ".nc" in dataset_info["id"]: - engine = "netcdf4" - ds = xarray.open_dataset(dataset_info["id"], engine=engine, chunks={}) - return ds - else: - raise ValueError(f'Could not find dataset at {dataset_info["id"]}') - - def _set_state_values(self, **kwargs): - if self._viewer is not None: - for k, v in kwargs.items(): - if self._viewer.state[k] != v: - self._viewer.state[k] = v - - def _auto_select_coordinates(self) -> None: - """Automatically assign available coordinates to available axes. - Automatic assignment is done according to the following expected coordinate names:\n - X: "x" | "i" | "lon" | "len"\n - Y: "y" | "j" | "lat" | "width"\n - Z: "z" | "k" | "depth" | "height"\n - T: "t" | "time" - """ - if self.x or self.y or self.z or self.t: - # Some coordinates already assigned, don't auto-assign - return - if self.dataset is not None and self.data_array_name is not None: - da = self.dataset[self.data_array_name] - assigned_coords = [] - unassigned_axes = [ - a for a in ["x", "y", "z", "t"] if getattr(self, a) is None - ] - # Prioritize assignment by known names - for coord_name in da.dims: - name = coord_name.lower() - for axis, accepted_names in coordinate_auto_selection.items(): - # If accepted name is longer than one letter, look for contains match - name_match = [ - accepted - for accepted in accepted_names - if (len(accepted) == 1 and accepted == name) - or (len(accepted) > 1 and accepted in name) - ] - if len(name_match) > 0 and axis in unassigned_axes: - setattr(self, axis, coord_name) - assigned_coords.append(coord_name) - # Update list of unassigned axes - unassigned_axes = [ - a for a in ["x", "y", "z", "t"] if getattr(self, a) is None - ] - # Then assign any remaining by index - unassigned_coords = [d for d in da.dims if d not in assigned_coords] - for i, d in enumerate(unassigned_coords): - if i < len(unassigned_axes): - setattr(self, unassigned_axes[i], d) - - def _auto_select_slicing( - self, - bounds: Optional[Dict] = None, - steps: Optional[Dict] = None, - ) -> None: - """Automatically select slicing for selected data array.""" - if not self.dataset or not self.data_array_name or self._resolution <= 1: - return - if not bounds: - da = self.dataset[self.data_array_name] - bounds = {k: [0, da[k].size] for k in da.dims} - self.slicing = { - k: [ - v[0], - v[1], - ( - math.ceil((v[1] - v[0]) / self._resolution) - if self._resolution > 1 and v[1] - v[0] > 0 and k != self.t - else steps.get(k, 1) if steps is not None and k != self.t else 1 - ), - ] - for k, v in bounds.items() - } - if self._viewer: - self._viewer._data_slicing_changed() - - # ----------------------------------------------------- - # Config logic - # ----------------------------------------------------- - - def import_config(self, config_file: Union[str, Path, None]) -> None: - """Import state from a JSON configuration file. - - Parameters: - config_file: Can be a dictionary containing state information, - or a string or Path referring to a JSON file which contains state information. - For details, see Configuration Files documentation. - """ - if isinstance(config_file, dict): - config = config_file - elif isinstance(config_file, str): - path = Path(config_file) - if path.exists(): - config = json.loads(path.read_text()) - else: - config = json.loads(config_file) - origin_config = config.get("data_origin") - array_config = config.get("data_array") - - if not origin_config or not array_config: - raise ValueError("Invalid format of import file.") - - if isinstance(origin_config, str): - origin_config = { - "source": "default", - "id": origin_config, - } - self._import_mode = True - self.dataset_info = origin_config - self.data_array_name = array_config.pop("name") - for key, value in array_config.items(): - setattr(self, key, value) - if config.get("data_slices"): - self._resolution = 1 # disable auto slicing - self.slicing = config.get("data_slices") - self._import_mode = False - - ui_config = {f"ui_{k}": v for k, v in config.get("ui", {}).items()} - render_config = {f"render_{k}": v for k, v in config.get("render", {}).items()} - if self._viewer: - self._set_state_values( - **ui_config, - **render_config, - ui_action_name=None, - ) - else: - self._import_viewer_state = dict( - **ui_config, - **render_config, - ) - - def export_config(self, config_file: Union[str, Path, None] = None) -> None: - """Export the current state to a JSON configuration file. - - Parameters: - config_file: Can be a string or Path representing the destination of the JSON configuration file. - If None, a dictionary containing the current configuration will be returned. - For details, see Configuration Files documentation. - """ - data_origin = self.dataset_info - if data_origin.get("source") == "default": - data_origin = data_origin.get("id") - config = { - "data_origin": data_origin, - "data_array": { - "name": self.data_array_name, - **{ - key: getattr(self, key) - for key in ["x", "y", "z", "t", "t_index"] - if getattr(self, key) is not None - }, - }, - } - if self._algorithm.slicing: - config["data_slices"] = self._algorithm.slicing - if self._viewer: - state_items = list(self._viewer.state.to_dict().items()) - config["ui"] = { - k.replace("ui_", ""): v - for k, v in state_items - if k.startswith("ui_") - and "action" not in k - and "loading" not in k - and "catalog" not in k - } - config["render"] = { - k.replace("render_", ""): v - for k, v in state_items - if k.startswith("render_") and "_options" not in k - } - - if config_file: - Path(config_file).write_text(json.dumps(config)) - return config diff --git a/pan3d/dataset_viewer.py b/pan3d/dataset_viewer.py deleted file mode 100644 index 7ee70a90..00000000 --- a/pan3d/dataset_viewer.py +++ /dev/null @@ -1,857 +0,0 @@ -import asyncio -import concurrent.futures -import itertools -import json -import pandas -import pyvista -import geovista -import numpy -import base64 -from io import BytesIO -from PIL import Image -from pathlib import Path -from typing import Dict, List, Optional, Union - -from trame.decorators import TrameApp, change -from trame.app import get_server -from trame.widgets import html, client -from trame.widgets import vuetify3 as vuetify -from trame_server.core import Server -from trame_server.state import State -from trame_server.controller import Controller -from trame_vuetify.ui.vuetify3 import VAppLayout - -from pan3d import catalogs as pan3d_catalogs -from pan3d.dataset_builder import DatasetBuilder -from pan3d.ui import AxisDrawer, MainDrawer, Toolbar, RenderOptions, BoundsConfigure -from pan3d.utils.constants import ( - initial_state, - has_gpu_rendering, -) - -BASE_DIR = Path(__file__).parent -CSS_FILE = BASE_DIR / "ui" / "custom.css" - - -@TrameApp() -class DatasetViewer: - """Create a Trame GUI for a DatasetBuilder instance and manage rendering""" - - def __init__( - self, - builder: Optional[DatasetBuilder] = None, - server: Union[Server, str] = None, - state: dict = None, - catalogs: List[str] = [], - ) -> None: - """Create an instance of the DatasetViewer class. - - Parameters: - builder: Pan3D DatasetBuilder instance. - server: Trame server name or instance. - state: A dictionary of initial state values. - catalogs: A list of strings referencing available catalog modules (options include 'pangeo', 'esgf'). Each included catalog will be available to search in the GeoTrame UI. - """ - if builder is None: - builder = DatasetBuilder() - builder._viewer = self - self.builder = builder - self.server = get_server(server, client_type="vue3") - self.current_event_loop = asyncio.get_event_loop() - self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - self._ui = None - self._default_style = CSS_FILE.read_text() - self._preview_slicing = None - - self.plotter = geovista.GeoPlotter(off_screen=True, notebook=False) - self.plotter.set_background("lightgrey") - self.reset_camera = True - self.plot_view = None - self.actor = None - self.ctrl.get_plotter = lambda: self.plotter - self.ctrl.on_client_connected.add(self._on_ready) - - self.state.update(initial_state) - self.state.ready() - if state: - self.state.update(state) - - if catalogs: - self.state.available_catalogs = [ - pan3d_catalogs.get(catalog_name) for catalog_name in catalogs - ] - - self._force_local_rendering = not has_gpu_rendering() - if self._force_local_rendering: - pyvista.global_theme.trame.default_mode = "client" - - self._dataset_changed() - self._data_array_changed() - self._time_index_changed() - self._mesh_changed() - - def _on_ready(self, **kwargs): - self.state.render_auto = True - self.reset_camera = True - self._mesh_changed() - - def start(self, **kwargs): - """Initialize the UI and start the server for GeoTrame.""" - self.ui.server.start(**kwargs) - - @property - async def ready(self) -> None: - """Coroutine to wait for the GeoTrame server to be ready.""" - await self.ui.ready - - @property - def state(self) -> State: - """Returns the current State of the Trame server.""" - return self.server.state - - @property - def ctrl(self) -> Controller: - """Returns the Controller for the Trame server.""" - return self.server.controller - - @property - def ui(self) -> VAppLayout: - """Constructs and returns a Trame UI for managing and viewing the current data.""" - if self._ui is None: - # Build UI - self._ui = VAppLayout(self.server) - with self._ui: - with client.Style(self._default_style) as style: - self.ctrl.update_style = style.update - Toolbar( - self.apply_and_render, - self._submit_import, - self.builder.export_config, - ) - MainDrawer( - update_catalog_search_term_function=self._update_catalog_search_term, - catalog_search_function=self._catalog_search, - catalog_term_search_function=self._catalog_term_option_search, - switch_data_group_function=self._switch_data_group, - ) - AxisDrawer( - coordinate_select_axis_function=self._coordinate_select_axis, - coordinate_change_slice_function=self._coordinate_change_slice, - coordinate_toggle_expansion_function=self._coordinate_toggle_expansion, - ) - with vuetify.VMain(): - vuetify.VBanner( - "{{ ui_error_message }}", - v_show=("ui_error_message",), - ) - with html.Div( - v_if=("da_active",), style="height: 100%; position: relative" - ): - BoundsConfigure( - coordinate_change_bounds_function=self._coordinate_change_bounds, - snap_camera_function=self._snap_camera_view_face, - ) - RenderOptions() - with pyvista.trame.ui.plotter_ui( - self.ctrl.get_plotter(), - interactive_ratio=1, - collapse_menu=True, - ) as plot_view: - self.ctrl.view_update = plot_view.update - self.ctrl.reset_camera = plot_view.reset_camera - self.ctrl.push_camera = plot_view.push_camera - self.plot_view = plot_view - # turn on axis orientation widget by default with state var from pyvista - # (typo in visibility is intentional, done to match pyvista) - self.state[f"{self.ctrl.get_plotter()._id_name}_axis_visiblity"] = True - return self._ui - - # ----------------------------------------------------- - # UI bound methods - # ----------------------------------------------------- - def _update_catalog_search_term(self, term_key, term_value): - self.state.catalog_current_search[term_key] = term_value - self.state.dirty("catalog_current_search") - - def _catalog_search(self): - def load_results(): - catalog_id = self.state.catalog.get("id") - results, group_name, message = pan3d_catalogs.search( - catalog_id, **self.state.catalog_current_search - ) - - if len(results) > 0: - self.state.available_data_groups.append( - {"name": group_name, "value": group_name} - ) - self.state.available_datasets[group_name] = results - self.state.ui_catalog_search_message = message - self.state.dirty("available_data_groups", "available_datasets") - else: - self.state.ui_catalog_search_message = ( - "No results found for current search criteria." - ) - - self.run_as_async( - load_results, - loading_state="ui_catalog_term_search_loading", - error_state="ui_catalog_search_message", - unapplied_changes_state=None, - ) - - def _catalog_term_option_search(self): - def load_terms(): - catalog_id = self.state.catalog.get("id") - search_options = pan3d_catalogs.get_search_options(catalog_id) - self.state.available_catalogs = [ - ( - { - **catalog, - "search_terms": [ - {"key": k, "options": v} for k, v in search_options.items() - ], - } - if catalog.get("id") == catalog_id - else catalog - ) - for catalog in self.state.available_catalogs - ] - for catalog in self.state.available_catalogs: - if catalog.get("id") == catalog_id: - self.state.catalog = catalog - - self.run_as_async( - load_terms, - loading_state="ui_catalog_term_search_loading", - error_state="ui_catalog_search_message", - unapplied_changes_state=None, - ) - - def _switch_data_group(self): - # Setup from previous group needs to be cleared - self.state.dataset_info = None - self.state.da_attrs = {} - self.state.da_vars = {} - self.state.da_vars_attrs = {} - self.state.da_coordinates = [] - self.state.ui_expanded_coordinates = [] - self.state.da_active = None - self.state.da_x = None - self.state.da_y = None - self.state.da_z = None - self.state.da_t = None - self.state.da_t_index = 0 - self.plotter.clear() - self.plotter.view_isometric() - - def _coordinate_select_axis( - self, coordinate_name, current_axis, new_axis, **kwargs - ): - if current_axis and self.state[current_axis]: - self.state[current_axis] = None - if new_axis and new_axis != "undefined": - self.state[new_axis] = coordinate_name - self.reset_camera = True - - def _coordinate_change_slice(self, coordinate_name, slice_attribute_name, value): - value = int(value) - for coord in self.state.da_coordinates: - if coord["name"] == coordinate_name: - bounds = coord.get("bounds") - if slice_attribute_name == "start": - bounds[0] = value - elif slice_attribute_name == "stop": - bounds[1] = value - elif slice_attribute_name == "step": - coord.update(dict(step=value)) - coord.update(dict(bounds=bounds)) - self.state.dirty("da_coordinates") - - def _coordinate_change_bounds(self, coordinate_name, bounds): - for coord in self.state.da_coordinates: - if coord["name"] == coordinate_name: - coord.update(dict(bounds=bounds)) - self.state.dirty("da_coordinates") - - def _coordinate_toggle_expansion(self, coordinate_name): - if coordinate_name in self.state.ui_expanded_coordinates: - self.state.ui_expanded_coordinates.remove(coordinate_name) - else: - self.state.ui_expanded_coordinates.append(coordinate_name) - self.state.dirty("ui_expanded_coordinates") - - def _submit_import(self): - def submit(): - files = self.state["ui_action_config_file"] - if files and len(files) > 0: - file_content = files[0]["content"] - self.plotter.clear() - self.plotter.view_isometric() - - self.builder.import_config(json.loads(file_content.decode())) - self._mesh_changed() - self.reset_camera = True - - self.run_as_async( - submit, loading_state="ui_import_loading", unapplied_changes_state=None - ) - - def _snap_camera_view_face(self): - face = self.state.cube_preview_face - if "X" in face: - viewUp = [0, 0, 1] - vector = [-1, -1, 1] if "+" in face else [1, 1, 1] - self.plotter.view_vector(vector, viewUp) - if "Y" in face: - viewUp = [0, 0, 1] - vector = [1, -1, 1] if "+" in face else [-1, 1, 1] - self.plotter.view_vector(vector, viewUp) - if "Z" in face: - viewUp = [0, 1, 0] - vector = [-1, 1, -1] if "+" in face else [1, 1, 1] - self.plotter.view_vector(vector, viewUp) - - # ----------------------------------------------------- - # Rendering methods - # ----------------------------------------------------- - - def set_render_scales(self, **kwargs: Dict[str, str]) -> None: - """Set the scales at which each axis (x, y, and/or z) should be rendered. - - Parameters: - kwargs: A dictionary mapping of axis names to integer scales.\n - Keys must be 'x' | 'y' | 'z'.\n - Values must be integers > 0. - """ - if "x" in kwargs and kwargs["x"] != self.state.render_x_scale: - self.state.render_x_scale = int(kwargs["x"]) - if "y" in kwargs and kwargs["y"] != self.state.render_y_scale: - self.state.render_y_scale = int(kwargs["y"]) - if "z" in kwargs and kwargs["z"] != self.state.render_z_scale: - self.state.render_z_scale = int(kwargs["z"]) - self.plotter.set_scale( - xscale=self.state.render_x_scale or 1, - yscale=self.state.render_y_scale or 1, - zscale=self.state.render_z_scale or 1, - ) - - def set_render_options( - self, - colormap: str = "viridis", - transparency: bool = False, - transparency_function: str = None, - scalar_warp: bool = False, - cartographic: bool = False, - render: bool = True, - ) -> None: - """Set available options for rendering data. - - Parameters: - colormap: A colormap name from Matplotlib (https://matplotlib.org/stable/users/explain/colors/colormaps.html) - transparency: If true, enable transparency and use transparency_function. - transparency_function: One of PyVista's opacity transfer functions (https://docs.pyvista.org/version/stable/examples/02-plot/opacity.html#transfer-functions) - scalar_warp: If true, warp the mesh proportional to its scalars. - cartographic: If true, wrap the mesh around an earth sphere. - render: If true, update current render with new values (default=True) - """ - if self.state.render_colormap != colormap: - self.state.render_colormap = colormap - if self.state.render_transparency != transparency: - self.state.render_transparency = transparency - if self.state.render_transparency_function != transparency_function: - self.state.render_transparency_function = transparency_function - if self.state.render_scalar_warp != scalar_warp: - self.state.render_scalar_warp = scalar_warp - if self.state.render_cartographic != cartographic: - self.state.render_cartographic = cartographic - - if ( - render - and self.builder.mesh is not None - and self.builder.data_array is not None - ): - self.apply_and_render() - - def plot_mesh(self) -> None: - """Render current cached mesh in GeoTrame plotter.""" - if self.builder.data_array is None: - return - - self.plotter.clear() - args = dict( - cmap=self.state.render_colormap, - clim=self.builder.data_range, - scalar_bar_args=dict(interactive=True), - ) - if self.state.render_transparency: - args["opacity"] = self.state.render_transparency_function - - if self.state.render_cartographic: - self.plotter.add_base_layer(texture=geovista.blue_marble()) - da = self.builder.data_array # slicing already applied - mesh = geovista.Transform.from_1d( - da[self.builder.x], # lon coordinates - da[self.builder.y], # lat coordinates - da, - ) - mesh = mesh.threshold() # make NaN values transparent - - # position camera - camera = self.plotter.camera - camera.focal_point = [0, 0, 0] - camera.position = mesh.center - self.plotter.reset_camera(bounds=mesh.bounds) - else: - mesh = self.builder.mesh - - if self.state.render_scalar_warp: - mesh = mesh.warp_by_scalar() - self.actor = self.plotter.add_mesh( - mesh, - **args, - ) - if self.reset_camera: - if len(self.builder.data_array.shape) > 2: - self.plotter.view_vector([1, 1, 1], [0, 1, 0]) - elif not self.state.render_cartographic: - self.plotter.view_xy() - self.reset_camera = False - - if self.plot_view: - self.ctrl.push_camera() - self.ctrl.view_update() - - def apply_and_render(self, **kwargs) -> None: - """Asynchronously reset and update cached mesh and render to viewer's plotter.""" - - self.run_as_async(self.plot_mesh) - - def run_as_async( - self, - function, - loading_state="ui_loading", - error_state="ui_error_message", - unapplied_changes_state="ui_unapplied_changes", - ): - async def run(): - with self.state: - if loading_state is not None: - self.state[loading_state] = True - if error_state is not None: - self.state[error_state] = None - if unapplied_changes_state is not None: - self.state[unapplied_changes_state] = False - - await asyncio.sleep(0.001) - - with self.state: - try: - function() - except Exception as e: - if error_state is not None: - self.state[error_state] = str(e) - else: - raise e - if loading_state is not None: - self.state[loading_state] = False - - await asyncio.sleep(0.001) - - if self.current_event_loop.is_running(): - asyncio.run_coroutine_threadsafe(run(), self.current_event_loop) - else: - # Pytest environment needs synchronous execution - function() - - # ----------------------------------------------------- - # State sync with Builder - # ----------------------------------------------------- - def _dataset_changed(self) -> None: - self.state.ui_more_info_link = None - self.state.da_attrs = {} - self.state.da_vars = {} - self.state.da_vars_attrs = {} - - dataset = self.builder.dataset - if dataset: - if self._ui is not None: - self.state.ui_main_drawer = True - - self.state.da_attrs = [ - {"key": str(k), "value": str(v)} for k, v in dataset.attrs.items() - ] - self.state.da_attrs.insert( - 0, - { - "key": "dimensions", - "value": str(dict(dataset.sizes)), - }, - ) - self.state.da_vars = [ - {"name": k, "id": i} for i, k in enumerate(dataset.data_vars.keys()) - ] - self.state.da_vars_attrs = { - var["name"]: [ - {"key": str(k), "value": str(v)} - for k, v in dataset.data_vars[var["name"]].attrs.items() - ] - for var in self.state.da_vars - } - if len(self.state.da_vars) == 0: - self.state.no_da_vars = True - self.state.dataset_ready = True - else: - self.state.dataset_ready = False - - def _get_datetime_label(self, dtype, v) -> str: - if dtype.kind in ["O", "M"]: # is datetime - try: - return pandas.to_datetime(v).strftime("%b %d %Y %H:%M") - except Exception: - # Get around the case where certain cftime objects do not - # readily agree with conversion to datetime objects - return str(v) - elif dtype.kind in ["m"]: # is timedelta - return f"{pandas.to_timedelta(v).total_seconds()} seconds" - elif isinstance(v, float): - return str(round(v)) - else: - return str(v) - - def _data_array_changed(self) -> None: - dataset = self.builder.dataset - da_name = self.builder.data_array_name - self.state.da_coordinates = [] - self.state.ui_expanded_coordinates = [] - self.reset_camera = True - - if dataset is None or da_name is None: - return - da = dataset[da_name] - for key in da.dims: - if key not in [c["name"] for c in self.state.da_coordinates]: - current_coord = da.coords[key] - values = list(current_coord.values) - reverse_order = values[0] > values[-1] - size = current_coord.size - dtype = current_coord.dtype - labels = [self._get_datetime_label(dtype, v) for v in values] - coord_attrs = [ - {"key": str(k), "value": str(v)} - for k, v in da.coords[key].attrs.items() - ] - coord_attrs.append({"key": "dtype", "value": str(dtype)}) - coord_attrs.append({"key": "length", "value": int(size)}) - coord_attrs.append( - {"key": "range", "value": f"{labels[0]} - {labels[-1]}"} - ) - bounds = [0, size - 1] - self.state.da_coordinates.append( - { - "name": key, - "attrs": coord_attrs, - "labels": labels, - "full_bounds": bounds, - "bounds": bounds, - "step": 1, - "reverse_order": str(reverse_order), - } - ) - self.state.dirty("da_coordinates") - self.plotter.clear() - self.plotter.view_isometric() - - def _data_slicing_changed(self) -> None: - if self.builder.slicing is None: - return - for coord in self.state.da_coordinates: - slicing = self.builder.slicing.get(coord["name"]) - if slicing: - bounds = [slicing[0], slicing[1] - 1] # stop is exclusive - if bounds != coord.get("bounds"): - coord.update(dict(bounds=bounds)) - self.state.dirty("da_coordinates") - - if slicing[2] != coord.get("step"): - coord.update(dict(step=slicing[2])) - self.state.dirty("da_coordinates") - self._generate_preview() - - def _time_index_changed(self) -> None: - self._generate_preview() - - def _generate_preview(self) -> None: - if ( - self.builder.dataset is None - or self.builder.data_array_name is None - or self.builder.slicing is None - or self._ui is None - ): - return - if not self.state.cube_view_mode: - self.ctrl.update_style(self._default_style) - return - preview_slicing = {} - if self.builder.t is not None and self.builder.t_index is not None: - preview_slicing[self.builder.t] = self.builder.t_index - - face_options = [] - if self.builder.x is not None and self.builder.y is not None: - face_options += ["+Z", "-Z"] - if self.builder.x is not None and self.builder.z is not None: - face_options += ["+Y", "-Y"] - if self.builder.y is not None and self.builder.z is not None: - face_options += ["+X", "-X"] - self.state.cube_preview_face_options = face_options - if self.state.cube_preview_face not in face_options and len(face_options): - self.state.cube_preview_face = face_options[0] - - axis_name = None - if "X" in self.state.cube_preview_face: - self.state.cube_preview_axes = dict( - x=self.builder.y, - y=self.builder.z, - ) - axis_name = self.builder.x - elif "Y" in self.state.cube_preview_face: - self.state.cube_preview_axes = dict( - x=self.builder.x, - y=self.builder.z, - ) - axis_name = self.builder.y - elif "Z" in self.state.cube_preview_face: - self.state.cube_preview_axes = dict( - x=self.builder.x, - y=self.builder.y, - ) - axis_name = self.builder.z - - if axis_name is not None: - axis_slicing = self.builder.slicing.get(axis_name) - if axis_slicing is not None: - preview_slicing[axis_name] = ( - axis_slicing[0] - if "+" in self.state.cube_preview_face - else axis_slicing[1] - 1 # stop is exclusive - ) - - # update CSS to make blue slider thumb match preview outline - thumb_selector = f'.{axis_name}-slider .v-slider-thumb[aria-valuenow="{preview_slicing[axis_name]}"]' - thumb_style = thumb_selector + " { color: rgb(0, 100, 255) }" - self.ctrl.update_style(self._default_style + thumb_style) - - if preview_slicing == self._preview_slicing: - return - self._preview_slicing = preview_slicing - - data = numpy.nan_to_num( - self.builder.dataset[self.builder.data_array_name] - .isel(preview_slicing) - .to_numpy() - ) - # if any dimensions are too small, increase size with gradients between values - min_dim_length = 50 - for axis_index in range(len(data.shape)): - length = data.shape[axis_index] - if length < min_dim_length: - gradient_steps = int(min_dim_length / (length - 1)) - gradients = [] - slices = list( - itertools.product( - *[ - [slice(None)] if i == axis_index else list(range(l)) - for i, l in enumerate(data.shape) - ] - ) - ) - for s in slices: - sdata = data[s] - gradient = [] - for i in range(len(sdata) - 1): - gradient_portion = list( - numpy.linspace( - sdata[i], sdata[i + 1], gradient_steps, endpoint=False - ) - ) - gradient += gradient_portion - gradient.append(sdata[-1]) - gradients.append(gradient) - stack_axis = next(i for i in range(len(data.shape)) if i != axis_index) - data = numpy.stack(gradients, axis=stack_axis) - - normalized_data = numpy.vectorize( - lambda x, x_min, x_max: (x - x_min) / (x_max - x_min) * 255 - )(data, numpy.min(data), numpy.max(data)).astype(numpy.uint8) - img = Image.fromarray(normalized_data) - - # apply transposes to match rendering orientation - reverse_x = False - reverse_y = False - for coord in self.state.da_coordinates: - if coord.get("name") == self.state.cube_preview_axes["x"]: - reverse_x = coord.get("reverse_order") == "True" - if coord.get("name") == self.state.cube_preview_axes["y"]: - reverse_y = coord.get("reverse_order") == "True" - if not reverse_y: - img = img.transpose(Image.FLIP_TOP_BOTTOM) - if reverse_x != "+" in self.state.cube_preview_face: - img = img.transpose(Image.FLIP_LEFT_RIGHT) - - # encode image data - buffer = BytesIO() - img.save(buffer, format="PNG") - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - - # save encoded to state - self.state.cube_preview = f"data:image/png;base64,{encoded}" - - def _mesh_changed(self) -> None: - da = self.builder.data_array - if da is None: - self.state.da_size = 0 - self.state.ui_unapplied_changes = False - return - total_bytes = da.size * da.dtype.itemsize - if total_bytes == 0: - self.state.da_size = "0 bytes" - exponents_map = {0: "bytes", 1: "KB", 2: "MB", 3: "GB"} - for exponent in sorted(exponents_map.keys(), reverse=True): - divisor = 1024**exponent - suffix = exponents_map[exponent] - if total_bytes > divisor: - self.state.da_size = f"{round(total_bytes / divisor)} {suffix}" - break - self.state.ui_unapplied_changes = True - - if self.state.render_auto: - self.apply_and_render() - - # ----------------------------------------------------- - # State change callbacks - # ----------------------------------------------------- - @change("ui_search_catalogs") - def _on_change_ui_search_catalogs(self, ui_search_catalogs, **kwargs): - if ui_search_catalogs: - self.state.catalog = self.state.available_catalogs[0] - else: - self.state.catalog = None - - @change("catalog") - def _on_change_catalog(self, catalog, **kwargs): - self.state.catalog_current_search = {} - self.state.ui_catalog_search_message = None - - @change("dataset_info") - def _on_change_dataset_info(self, dataset_info, **kwargs): - self.plotter.clear() - self.plotter.view_isometric() - - if dataset_info is not None: - dataset_exists = False - for dataset_group in self.state.available_datasets.values(): - for d in dataset_group: - if d["value"] == dataset_info: - dataset_exists = True - self.state.ui_more_info_link = d.get("link") - if not dataset_exists: - self.state.available_data_groups = [ - "default", - *self.state.available_data_groups, - ] - self.state.data_group = "default" - self.state.available_datasets["default"] = [ - { - "value": dataset_info, - "name": dataset_info["id"], - } - ] - self.state.dirty("available_datasets") - - def load_dataset(): - self.builder.dataset_info = dataset_info - - self.run_as_async(load_dataset, unapplied_changes_state=None) - - @change("da_active") - def _on_change_da_active(self, da_active, **kwargs): - self.builder.data_array_name = da_active - - @change("da_x") - def _on_change_da_x(self, da_x, **kwargs): - self.builder.x = da_x - - @change("da_y") - def _on_change_da_y(self, da_y, **kwargs): - self.builder.y = da_y - - @change("da_z") - def _on_change_da_z(self, da_z, **kwargs): - self.builder.z = da_z - - @change("da_t") - def _on_change_da_t(self, da_t, **kwargs): - self.builder.t = da_t - - @change("da_t_index") - def _on_change_da_t_index(self, da_t_index, **kwargs): - self.builder.t_index = da_t_index - - @change("da_coordinates") - def _on_change_da_coordinates(self, da_coordinates, **kwargs): - bounds = { - c.get("name"): [c["bounds"][0], c["bounds"][1] + 1] # stop is exclusive - for c in da_coordinates - } - steps = {c.get("name"): c.get("step") for c in da_coordinates} - self.builder._auto_select_slicing(bounds, steps) - - @change("ui_action_name") - def _on_change_action_name(self, ui_action_name, **kwargs): - self.state.ui_action_message = None - self.state.ui_action_config_file = None - if ui_action_name == "Export": - self.state.state_export = self.builder.export_config(None) - - @change("render_x_scale", "render_y_scale", "render_z_scale") - def _on_change_render_scales( - self, render_x_scale, render_y_scale, render_z_scale, **kwargs - ): - self.set_render_scales( - x=int(render_x_scale), y=int(render_y_scale), z=int(render_z_scale) - ) - - @change( - "render_colormap", - "render_transparency", - "render_transparency_function", - "render_scalar_warp", - "render_cartographic", - ) - def _on_change_render_options( - self, - render_colormap, - render_transparency, - render_transparency_function, - render_scalar_warp, - render_cartographic, - **kwargs, - ): - self.set_render_options( - colormap=render_colormap, - transparency=render_transparency, - transparency_function=render_transparency_function, - scalar_warp=render_scalar_warp, - cartographic=render_cartographic, - ) - - @change("cube_view_mode", "cube_preview_face") - def _on_change_cube_view(self, cube_view_mode, cube_preview_face, **kwargs): - self._generate_preview() - - @change("ui_main_drawer") - def _on_change_ui_main_drawer(self, ui_main_drawer, **kwargs): - self.state.ui_bounds_menu = False - - @change("ui_axis_drawer") - def _on_change_ui_axis_drawer(self, ui_axis_drawer, **kwargs): - self.state.ui_render_options_menu = False diff --git a/pan3d/explorers/slice_explorer.py b/pan3d/explorers/slicer.py similarity index 57% rename from pan3d/explorers/slice_explorer.py rename to pan3d/explorers/slicer.py index b521c475..ab0d0a28 100644 --- a/pan3d/explorers/slice_explorer.py +++ b/pan3d/explorers/slicer.py @@ -1,25 +1,23 @@ +import sys +import json import vtk -import numpy as np -import pandas as pd from pathlib import Path +from pan3d.xarray.algorithm import vtkXArrayRectilinearSource + from trame.app import get_server from trame_client.widgets.core import TrameDefault from trame.decorators import TrameApp, change from trame.ui.vuetify3 import SinglePageWithDrawerLayout -from trame.widgets import vuetify3 as v3, vtk as vtkw, html, client -from pan3d.dataset_builder import DatasetBuilder -from pan3d.ui.common import NumericField +from trame.widgets import vuetify3 as v3, html, client +from pan3d.ui.vtk_view import Pan3DView +from pan3d.ui.common import NumericField from pan3d.utils.presets import update_preset, use_preset, COLOR_PRESETS -def get_time_labels(times): - return [pd.to_datetime(time).strftime("%Y-%m-%d %H:%M:%S") for time in times] - - @TrameApp() -class SliceExplorer: +class XArraySlicer: """ A Trame based pan3D explorer to visualize 3D using slices along different dimensions @@ -28,7 +26,7 @@ class SliceExplorer: using VTK while interacting with the slice in 2D or 3D. """ - def __init__(self, builder: DatasetBuilder = None, server=None): + def __init__(self, source=None, server=None): # trame setup self.server = get_server(server) if self.server.hot_reload: @@ -36,113 +34,107 @@ def __init__(self, builder: DatasetBuilder = None, server=None): # CLI parser = self.server.cli - parser.add_argument("--config-path", required=(builder is None)) + parser.add_argument( + "--import-state", + help="Pass a string with this argument to specify a startup configuration. This value must be a local path to a JSON file which adheres to the schema specified in the [Configuration Files documentation](../api/configuration.md).", + required=(source is None), + ) args, _ = parser.parse_known_args() - # Setup builder if needed - self.builder = builder - if self.builder is None: - config_path = Path(args.config_path).resolve() - if config_path.exists(): - self.builder = DatasetBuilder() - self.builder.import_config(str(config_path)) - else: - print(f'--config-path "{str(config_path)}" must point to a valid path.') - exit(1) - - self.dims = dict( - [ - (x, y) - for (x, y) in zip( - [self.builder.x, self.builder.y, self.builder.z], ["x", "y", "z"] - ) - if x is not None - ] - ) - self.extents = self.builder.extents - - slice_dim = list(self.dims.keys())[0] - ext = self.extents[slice_dim] - self.state.update( - dict( - slice_dim=slice_dim, - dimval=float(ext[0] + (ext[1] - ext[0]) / 2), - dimmin=float(ext[0]), - dimmax=float(ext[1]), - varmin=0.0, - varmax=0.0, - t_labels=get_time_labels(self.builder.t_values), - x_scale=1.0, - y_scale=1.0, - z_scale=1.0, - ) - ) + # Check if we have what we need + config_file = Path(args.import_state) if args.import_state else None + if (config_file is None or not config_file.exists()) and source is None: + parser.print_help() + sys.exit(0) - self.t_cache = {} - self.vars = list(self.builder.dataset.data_vars.keys()) - self.var_ranges = self.builder.var_ranges + # Build Viz and UI + self.ui = None + self._setup_vtk(source, config_file) + self._build_ui() - extents = list(self.extents.values()) + def _setup_vtk(self, source=None, import_state=None): + if source is not None: + self.source = source + elif import_state is not None: + self.source = vtkXArrayRectilinearSource() + self.source.load(json.loads(import_state.read_text())) + else: + print( + "XArraySlicer can only work when passed a data source or a state to import." + ) + sys.exit(1) + + # Extract data info + ds = self.source() + bounds = ds.bounds + self.normal = [0, 0, 1] self.origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2), - ] - self.normal = [1, 0, 0] - self.lengths = [ - float(extents[0][1] - extents[0][0]), - float(extents[1][1] - extents[1][0]), - float(extents[2][1] - extents[2][0]), + 0.5 * (bounds[0] + bounds[1]), + 0.5 * (bounds[2] + bounds[3]), + 0.5 * (bounds[4] + bounds[5]), ] - self._generate_vtk_pipeline() - self._build_ui() - - def _generate_vtk_pipeline(self): - self._renderer = vtk.vtkRenderer() - self._interactor = vtk.vtkRenderWindowInteractor() - self._render_window = vtk.vtkRenderWindow() - var_range = self.var_ranges[self.vars[0]] + # Update state from dataset + self.state.t_index = self.source.t_index + self.state.bounds = bounds + self.state.cut_x = self.origin[0] + self.state.cut_y = self.origin[1] + self.state.cut_z = self.origin[2] + self.state.available_fields = list(ds.point_data.keys()) + self.state.color_by = self.state.available_fields[0] + self.state.t_labels = self.source.t_labels + self.state.slice_axes = [self.source.x, self.source.y, self.source.z] + self.state.slice_axis = self.source.z + + color_range = ds.point_data[self.state.color_by].GetRange() + + # Build rendering pipeline + self.renderer = vtk.vtkRenderer() + self.interactor = vtk.vtkRenderWindowInteractor() + self.render_window = vtk.vtkRenderWindow() plane = vtk.vtkPlane() plane.SetOrigin(self.origin) plane.SetNormal(self.normal) cutter = vtk.vtkCutter() cutter.SetCutFunction(plane) - cutter.SetInputData(self.builder.mesh) + cutter.input_connection = self.source.output_port slice_actor = vtk.vtkActor() slice_mapper = vtk.vtkDataSetMapper() slice_mapper.SetInputConnection(cutter.GetOutputPort()) - slice_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) + slice_mapper.SetScalarRange(*color_range) + slice_mapper.SelectColorArray(self.state.color_by) + slice_mapper.SetScalarModeToUsePointFieldData() + slice_mapper.InterpolateScalarsBeforeMappingOn() slice_actor.SetMapper(slice_mapper) - self._plane = plane - self._cutter = cutter - self._slice_actor = slice_actor - self._slice_mapper = slice_mapper + self.plane = plane + self.cutter = cutter + self.slice_actor = slice_actor + self.slice_mapper = slice_mapper outline = vtk.vtkOutlineFilter() outline_actor = vtk.vtkActor() outline_mapper = vtk.vtkPolyDataMapper() - outline.SetInputData(self.builder.mesh) + outline.input_connection = self.source.output_port outline_mapper.SetInputConnection(outline.GetOutputPort()) outline_actor.SetMapper(outline_mapper) outline_actor.GetProperty().SetColor(0.5, 0.5, 0.5) - self._outline = outline - self._outline_actor = outline_actor - self._outline_mapper = outline_mapper + self.outline = outline + self.outline_actor = outline_actor + self.outline_mapper = outline_mapper data_actor = vtk.vtkActor() data_mapper = vtk.vtkDataSetMapper() - data_mapper.SetInputData(self.builder.mesh) - data_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) + data_mapper.input_connection = self.source.output_port + data_mapper.SetScalarRange(*color_range) data_actor.SetMapper(data_mapper) data_actor.GetProperty().SetOpacity(0.1) data_actor.SetVisibility(False) - self._data_actor = data_actor - self._data_mapper = data_mapper + self.data_actor = data_actor + self.data_mapper = data_mapper sbar_actor = vtk.vtkScalarBarActor() - sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) + sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) sbar_actor.SetMaximumHeightInPixels(600) sbar_actor.SetMaximumWidthInPixels(100) sbar_actor.SetTitleRatio(0.2) @@ -150,20 +142,20 @@ def _generate_vtk_pipeline(self): lprop.SetColor(0.5, 0.5, 0.5) tprop: vtk.vtkTextProperty = sbar_actor.GetTitleTextProperty() tprop.SetColor(0.5, 0.5, 0.5) - self._sbar_actor = sbar_actor + self.sbar_actor = sbar_actor - self._renderer.SetBackground(1.0, 1.0, 1.0) - self._render_window.OffScreenRenderingOn() - self._render_window.AddRenderer(self._renderer) - self._interactor.SetRenderWindow(self._render_window) - self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.renderer.SetBackground(1.0, 1.0, 1.0) + self.render_window.OffScreenRenderingOn() + self.render_window.AddRenderer(self.renderer) + self.interactor.SetRenderWindow(self.render_window) + self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - self._renderer.AddActor(self._outline_actor) - self._renderer.AddActor(self._data_actor) - self._renderer.AddActor(self._slice_actor) - self._renderer.AddActor(self._sbar_actor) + self.renderer.AddActor(self.outline_actor) + self.renderer.AddActor(self.data_actor) + self.renderer.AddActor(self.slice_actor) + self.renderer.AddActor(self.sbar_actor) - self._renderer.ResetCamera() + self.renderer.ResetCamera() # ------------------------------------------------------------------------- # Trame API @@ -185,51 +177,19 @@ def start(self, **kwargs): # ------------------------------------------------------------------------- @property - def coords_time(self): + def slice_axis(self): """ - Returns the values for time coordinates for user to select + Returns the active axis along which the slice is performed """ - return self.builder.dataset.coords[self.builder.t].to_numpy() + return self.state.slice_axis - @property - def t_slice(self): - """ - Property representing the current time slice for the dataset from the - DatasetBuilder, and is extracted by setting the current active time value - as a xarray selection for the time slice - """ - datavar = self.state.data_var - # m = self.state.time_active - m = self.builder.t_values[self.state.time_active] - mesh = self.t_cache.get((m, datavar)) - ttype = self.builder.dataset.coords[self.builder.t].dtype - if mesh is None: - criteria = {} - if ttype.kind in ["O", "M"]: - criteria[self.builder.t] = np.datetime64(m, "ns") - else: - criteria[self.builder.t] = m - da = self.builder.dataset[datavar].sel(**criteria, method="nearest") - da.load() - mesh = da.pyvista.mesh(x=self.builder.x, y=self.builder.y, z=self.builder.z) - self.t_cache[(m, datavar)] = mesh - return mesh - - @property - def slice_dimension(self): + @slice_axis.setter + def slice_axis(self, axis: str) -> None: """ - Returns the active dimension along with the slice is performed - """ - return self.state.slice_dim - - @slice_dimension.setter - def slice_dimension(self, dim: str) -> None: - """ - Sets the active dimension along which the slice is performed + Sets the active axis along which the slice is performed """ with self.state: - self.state.slice_dim = dim - self.update_slicer_dimension(dim) + self.state.slice_axis = axis @property def slice_value(self): @@ -237,7 +197,9 @@ def slice_value(self): Returns the value(origin) for the dimension along which the slice is performed """ - return self.state.dimval + s = self.state + axis = "xyz"[s.slice_axes.index(s.slice_axis)] + return s[f"cut_{axis}"] @slice_value.setter def slice_value(self, value: float) -> None: @@ -246,7 +208,9 @@ def slice_value(self, value: float) -> None: is performed """ with self.state: - self.state.dimval = value + s = self.state + axis = "xyz"[s.slice_axes.index(s.slice_axis)] + s[f"cut_{axis}"] = value @property def view_mode(self): @@ -275,9 +239,9 @@ def scale_axis(self, sfac): s.x_scale = float(sfac[0]) s.y_scale = float(sfac[1]) s.z_scale = float(sfac[2]) - self._slice_actor.SetScale(s.x_scale, s.y_scale, s.z_scale) - self._data_actor.SetScale(s.x_scale, s.y_scale, s.z_scale) - self._outline_actor.SetScale(s.x_scale, s.y_scale, s.z_scale) + self.slice_actor.SetScale(*sfac) + self.data_actor.SetScale(*sfac) + self.outline_actor.SetScale(*sfac) self.on_view_mode_change(s.view_mode) @property @@ -305,7 +269,7 @@ def _on_colormap_change(self, cmap, **_): Performs all the steps necessary to visualize correct data when the color map is updated """ - use_preset(self._slice_actor, self._data_actor, self._sbar_actor, cmap) + use_preset(self.slice_actor, self.data_actor, self.sbar_actor, cmap) self.ctrl.view_update() @change("logscale") @@ -313,38 +277,32 @@ def _on_log_scale_change(self, logscale, **_): """ Performs all the steps necessary when user toggles log scale for color map """ - update_preset(self._slice_actor, self._sbar_actor, logscale) + update_preset(self.slice_actor, self.sbar_actor, logscale) self.ctrl.view_update() - def _set_view_2D(self, origin, dimension): - camera = self._renderer.GetActiveCamera() - position = camera.GetPosition() - norm = np.linalg.norm(np.array(origin) - np.array(position)) - position = origin[:] - self._outline_actor.SetVisibility(False) - self._data_actor.SetVisibility(False) - position[dimension] = norm - view_up = [0, 0.0, 1.0] if dimension == 1 else [0, 1.0, 0.0] - camera.SetPosition(position) - camera.SetViewUp(view_up) - camera.SetFocalPoint(origin) + def _set_view_2D(self, axis): + camera = self.renderer.GetActiveCamera() + view_up = [0, 0, 1] if axis == 1 else [0, 1, 0] + direction = [0, 0, 0] + direction[axis] = 1 + camera.SetFocalPoint(0, 0, 0) + camera.SetPosition(*direction) + camera.SetViewUp(*view_up) camera.OrthogonalizeViewUp() - self._renderer.SetActiveCamera(camera) - self._renderer.ResetCamera() + + self.outline_actor.SetVisibility(False) + self.data_actor.SetVisibility(False) + + self.renderer.ResetCamera() self.ctrl.view_update() - def _set_view_3D(self, origin): - camera = self._renderer.GetActiveCamera() - position = camera.GetPosition() - norm = np.linalg.norm(np.array(origin) - np.array(position)) - camera.SetPosition(norm, norm, norm) - camera.SetViewUp(0.0, 1.0, 0.0) + def _set_view_3D(self): if self.state.outline: - self._outline_actor.SetVisibility(True) + self.outline_actor.SetVisibility(True) if self.state.tdata: - self._data_actor.SetVisibility(True) - self._renderer.SetActiveCamera(camera) - self._renderer.ResetCamera() + self.data_actor.SetVisibility(True) + + self.renderer.ResetCamera() self.ctrl.view_update() @change("view_mode") @@ -352,64 +310,51 @@ def on_view_mode_change(self, view_mode, **_): """ Performs all the steps necessary when user toggles the view mode """ - s = self.state - slice_dim = s.slice_dim - slice_i = ( - 0 - if self.dims[slice_dim] == "x" - else 1 if self.dims[slice_dim] == "y" else 2 - ) - extents = list(self.extents.values()) - origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2) * s.x_scale, - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2) * s.y_scale, - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2) * s.z_scale, - ] if view_mode == "3D": - self._set_view_3D(origin) + self._set_view_3D() elif view_mode == "2D": - self._set_view_2D(origin, slice_i) + s = self.state + axis_idx = s.slice_axes.index(s.slice_axis) + self._set_view_2D(axis_idx) - @change("data_var", "time_active", "dimval") - def _on_data_change(self, **_): + @change("color_by") + def _on_color_by_change(self, color_by, **_): + if color_by is None: + return + + color_min, color_max = self.source().point_data[color_by].GetRange() + + self.slice_mapper.SetScalarRange(color_min, color_max) + self.slice_mapper.SelectColorArray(color_by) + self.sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) + + self.state.color_min = color_min + self.state.color_max = color_max + + @change("slice_axis", "t_index", "cut_x", "cut_y", "cut_z") + def _on_data_change( + self, slice_axis, slice_axes, t_index, cut_x, cut_y, cut_z, **_ + ): """ Performs all the steps necessary when the user updates any properties that requires a new data update. E.g. changing the data variable for visualization, or changing active time, or changing slice value. """ - dimval = self.state.dimval - slice_dim = self.state.slice_dim - normal = [0, 0, 0] - slice_i = ( - 0 - if self.dims[slice_dim] == "x" - else 1 if self.dims[slice_dim] == "y" else 2 - ) - normal[slice_i] = 1 - - extents = list(self.extents.values()) - origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2), + self.normal = [0, 0, 0] + self.normal[slice_axes.index(slice_axis)] = 1 + self.origin = [ + float(cut_x), + float(cut_y), + float(cut_z), ] - origin[slice_i] = dimval - self._plane.SetOrigin(origin) - self._plane.SetNormal(normal) - - self._cutter.SetInputData(self.t_slice) - self._cutter.Update() - output = self._cutter.GetOutput() - vrange = output.GetPointData().GetArray(self.state.data_var).GetRange() - self._slice_mapper.SetScalarRange(float(vrange[0]), float(vrange[1])) - self.state.varmin = float(vrange[0]) - self.state.varmax = float(vrange[1]) - self._sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) + self.source.t_index = t_index + self.plane.SetOrigin(self.origin) + self.plane.SetNormal(self.normal) if self.state.view_mode == "2D": self.on_view_mode_change("2D") - self._renderer.ResetCamera() + self.renderer.ResetCamera() self.ctrl.view_update() @@ -418,51 +363,40 @@ def _on_rep_change(self, outline, tdata, **_): """ Performs all the steps necessary when user specifies 3D interaction options """ - self._outline_actor.SetVisibility(outline) - self._data_actor.SetVisibility(tdata) + self.outline_actor.SetVisibility(outline) + self.data_actor.SetVisibility(tdata) self.ctrl.view_update() - @change("varmin", "varmax") - def _on_scalar_change(self, varmin, varmax, **_): + @change("color_min", "color_max") + def _on_scalar_change(self, color_min, color_max, **_): """ Performs all the steps necessary when user specifies values for scalar range explicitly """ - self._slice_mapper.SetScalarRange(float(varmin), float(varmax)) - self._sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) + self.slice_mapper.SetScalarRange(float(color_min), float(color_max)) + self.sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) self.ctrl.view_update() # ------------------------------------------------------------------------- # UI triggers # ------------------------------------------------------------------------- - def on_axis_scale_change(self, axis, value): + def on_axis_scale_change(self, idx, value): """ Performs all the steps necessary when user specifies scaling along a certain axis """ s = self.state axis_names = ["x_scale", "y_scale", "z_scale"] - axis_name = axis_names[axis] - # If value is the same as previous no scaling is needed - if s[axis_name] == value: + if s[axis_names[idx]] == value: return - # Update all the actors to scale based on new value - s[axis_name] = value + + s[axis_names[idx]] = value scales = [s[n] for n in axis_names] - self._slice_actor.SetScale(*scales) - self._data_actor.SetScale(*scales) - self._outline_actor.SetScale(*scales) + self.slice_actor.SetScale(*scales) + self.data_actor.SetScale(*scales) + self.outline_actor.SetScale(*scales) # Update view self.on_view_mode_change(s.view_mode) - def update_slicer_dimension(self, dim): - """ - Update values for min/max and current slice values - """ - ext = self.extents[dim] - self.state.dimval = float(ext[0] + (ext[1] - ext[0]) / 2) - self.state.dimmin = float(ext[0]) - self.state.dimmax = float(ext[1]) - # ------------------------------------------------------------------------- # GUI definition # ------------------------------------------------------------------------- @@ -488,17 +422,16 @@ def _build_ui(self, *args, **kwargs): ) with layout.title as title: - title.set_text("Pan3D: Slice Explorer") + title.set_text("XArray Slicer") title.style = "flex: none;" - self.state.trame__title = "Slice Explorer" + self.state.trame__title = "XArray Slicer" v3.VSpacer() v3.VSelect( prepend_inner_icon="mdi-palette", - # label="Color By", - v_model=("data_var", next(iter(self.vars))), - items=("data_vars", self.vars), + v_model=("color_by", None), + items=("available_fields", []), variant="outlined", max_width="25vw", **style, @@ -507,16 +440,17 @@ def _build_ui(self, *args, **kwargs): with v3.VBtnToggle( v_model=( - "rep_options", + "representation_mode", TrameDefault( - rep_options=["outline"], outline=True, tdata=False + representation_mode=["outline"], + outline=True, + tdata=False, ), ), multiple=True, variant="outlined", **style, disabled=("view_mode === '2D'",), - classes="mx-4", ): v3.VBtn( icon="mdi-cube-outline", @@ -533,28 +467,12 @@ def _build_ui(self, *args, **kwargs): v_model=("view_mode", "3D"), mandatory=True, variant="outlined", + classes="mx-4", **style, ): v3.VBtn(icon="mdi-video-2d", value="2D") v3.VBtn(icon="mdi-video-3d", value="3D") - v3.VBtn( - icon="mdi-crop-free", - click=self.ctrl.view_reset_camera, - classes="ml-4", - ) - - v3.VSwitch( - v_model=("vscroll", False), - classes="mx-4", - **style, - false_icon="mdi-rotate-3d", - true_icon="mdi-arrow-vertical-lock", - inset=True, - color="red", - base_color="green", - ) - # Footer if not self.server.hot_reload: layout.footer.hide() @@ -574,13 +492,13 @@ def _build_ui(self, *args, **kwargs): html.Span("Time", classes="text-h6 font-weight-medium") v3.VSpacer() html.Span( - "{{t_labels[time_active]}}", classes="text-subtitle-1" + "{{t_labels[t_index]}}", classes="text-subtitle-1" ) v3.VSlider( classes="mx-2", min=0, - max=("t_size", self.builder.t_size - 1), - v_model=("time_active", 0), + max=self.source.t_size - 1, + v_model=("t_index", 0), step=1, **style, ) @@ -601,35 +519,57 @@ def _build_ui(self, *args, **kwargs): html.Span("Slice", classes="text-h6 font-weight-medium") v3.VSpacer() html.Span( - "{{parseFloat(dimval).toFixed(2)}}", + "{{parseFloat(cut_x).toFixed(2)}}", + v_show="slice_axis === slice_axes[0]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_y).toFixed(2)}}", + v_show="slice_axis === slice_axes[1]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_z).toFixed(2)}}", + v_show="slice_axis === slice_axes[2]", classes="text-subtitle-1", ) v3.VSelect( - v_model=("slice_dim",), - items=("slice_dims", list(self.dims.keys())), - update_modelValue=( - self.update_slicer_dimension, - "[$event]", - ), + v_model=("slice_axis",), + items=("slice_axes", []), **style, ) v3.VSlider( - v_model=("dimval",), - min=("dimmin",), - max=("dimmax",), + v_show="slice_axis === slice_axes[0]", + v_model=("cut_x",), + min=("bounds[0]",), + max=("bounds[1]",), + **style, + ) + v3.VSlider( + v_show="slice_axis === slice_axes[1]", + v_model=("cut_y",), + min=("bounds[2]",), + max=("bounds[3]",), + **style, + ) + v3.VSlider( + v_show="slice_axis === slice_axes[2]", + v_model=("cut_z",), + min=("bounds[4]",), + max=("bounds[5]",), **style, ) with v3.VRow(): with v3.VCol(): html.Div( - "{{parseFloat(dimmin).toFixed(2)}}", + "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2]).toFixed(2)}}", classes="font-weight-medium", ) with v3.VCol(classes="text-right"): html.Div( - "{{parseFloat(dimmax).toFixed(2)}}", + "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", classes="font-weight-medium", ) @@ -644,8 +584,8 @@ def _build_ui(self, *args, **kwargs): classes="text-center font-weight-medium d-flex flex-column", ): NumericField( - label=("slice_dims[0]",), - model_value=("x_scale",), + label=("slice_axes[0]",), + model_value=("x_scale", 1), update_event=( self.on_axis_scale_change, "[0, Number($event.target.value)]", @@ -657,8 +597,8 @@ def _build_ui(self, *args, **kwargs): classes="text-center font-weight-medium d-flex flex-column", ): NumericField( - label=("slice_dims[1]",), - model_value=("y_scale",), + label=("slice_axes[1]",), + model_value=("y_scale", 1), update_event=( self.on_axis_scale_change, "[1, Number($event.target.value)]", @@ -670,8 +610,8 @@ def _build_ui(self, *args, **kwargs): classes="text-center font-weight-medium d-flex flex-column", ): NumericField( - label=("slice_dims[2]",), - model_value=("z_scale",), + label=("slice_axes[2]",), + model_value=("z_scale", 1), update_event=( self.on_axis_scale_change, "[2, Number($event.target.value)]", @@ -704,14 +644,14 @@ def _build_ui(self, *args, **kwargs): with v3.VRow(classes="mt-1"): with v3.VCol(): v3.VTextField( - v_model=("varmin",), + v_model=("color_min",), label="min", outlined=True, **style, ) with v3.VCol(): v3.VTextField( - v_model=("varmax",), + v_model=("color_max",), label="max", outlined=True, **style, @@ -719,20 +659,11 @@ def _build_ui(self, *args, **kwargs): # Content with layout.content: - # 3d view - with html.Div( - style="position:absolute; top: 0; left: 0; width: 100%; height: 100%;", - ): - with vtkw.VtkRemoteView( - self._render_window, interactive_ratio=1 - ) as view: - self.ctrl.view_update = view.update - self.ctrl.view_reset_camera = view.reset_camera - - # Scroll locking overlay - html.Div( - v_show="vscroll", - style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;", + # 3D view + Pan3DView( + self.render_window, + axis_names="slice_axes", + style="position: absolute; left: 0; top: var(--v-layout-top); bottom: var(--v-layout-bottom); z-index: 0; width: 100%;", ) # Sliders overlay @@ -742,28 +673,53 @@ def _build_ui(self, *args, **kwargs): style="position: absolute; left: 0; top: var(--v-layout-top); bottom: var(--v-layout-bottom); z-index: 2; pointer-events: none; min-width: 5rem;", ): html.Div( - "{{slice_dim}}", + "{{slice_axis}}", classes="text-subtitle-1 text-capitalize text-left", style="transform-origin: 50% 50%; transform: rotate(-90deg) translateX(-100%) translateY(-1rem); position: absolute;", ) html.Div( - "{{parseFloat(dimmax).toFixed(2)}}", + "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", classes="text-subtitle-1 mx-1", ) v3.VSlider( + v_show="slice_axis === slice_axes[0]", + thumb_label="always", + thumb_size=16, + style="pointer-events: auto;", + hide_details=True, + classes="flex-fill", + direction="vertical", + v_model=("cut_x",), + min=("bounds[0]",), + max=("bounds[1]",), + ) + v3.VSlider( + v_show="slice_axis === slice_axes[1]", + thumb_label="always", + thumb_size=16, + style="pointer-events: auto;", + hide_details=True, + classes="flex-fill", + direction="vertical", + v_model=("cut_y",), + min=("bounds[2]",), + max=("bounds[3]",), + ) + v3.VSlider( + v_show="slice_axis === slice_axes[2]", thumb_label="always", thumb_size=16, style="pointer-events: auto;", hide_details=True, classes="flex-fill", direction="vertical", - v_model=("dimval",), - min=("dimmin",), - max=("dimmax",), + v_model=("cut_z",), + min=("bounds[4]",), + max=("bounds[5]",), ) html.Div( - "{{parseFloat(dimmin).toFixed(2)}}", + "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2]).toFixed(2)}}", classes="text-subtitle-1 mx-1", ) with html.Div( @@ -777,7 +733,7 @@ def _build_ui(self, *args, **kwargs): classes="text-subtitle-1", ) html.Div( - "Time: {{t_labels[time_active]}}", + "Time: {{t_labels[t_index]}}", classes="text-subtitle-1", ) html.Div( @@ -789,16 +745,16 @@ def _build_ui(self, *args, **kwargs): classes="mt-n2 mb-1", hide_details=True, min=0, - max=self.builder.t_size - 1, - v_model=("time_active", 0), + max=self.source.t_size - 1, + v_model=("t_index", 0), step=1, ) return layout def main(): - app = SliceExplorer() - app.server.start() + app = XArraySlicer() + app.start() if __name__ == "__main__": diff --git a/pan3d/serve_geotrame.py b/pan3d/serve_geotrame.py deleted file mode 100644 index e4b650a6..00000000 --- a/pan3d/serve_geotrame.py +++ /dev/null @@ -1,31 +0,0 @@ -from argparse import ArgumentParser, BooleanOptionalAction -from pan3d.dataset_builder import DatasetBuilder - - -def serve(): - parser = ArgumentParser( - prog="Pan3D", - description="Launch the Pan3D GeoTrame App", - ) - - parser.add_argument("--config_path") - parser.add_argument("--dataset") - parser.add_argument("--resolution", type=int) - parser.add_argument("--catalogs", nargs="+") - parser.add_argument("--server", action=BooleanOptionalAction) - parser.add_argument("--debug", action=BooleanOptionalAction) - - args = parser.parse_args() - - builder = DatasetBuilder( - dataset=args.dataset, - catalogs=args.catalogs, - resolution=args.resolution, - ) - if args.config_path: - builder.import_config(args.config_path) - builder.viewer.start(debug=args.debug) - - -if __name__ == "__main__": - serve() diff --git a/pan3d/ui/file_select.py b/pan3d/ui/__file_select.py similarity index 100% rename from pan3d/ui/file_select.py rename to pan3d/ui/__file_select.py diff --git a/pan3d/ui/__init__.py b/pan3d/ui/__init__.py index a560aa85..e69de29b 100644 --- a/pan3d/ui/__init__.py +++ b/pan3d/ui/__init__.py @@ -1,13 +0,0 @@ -from .axis_drawer import AxisDrawer -from .main_drawer import MainDrawer -from .toolbar import Toolbar -from .render_options import RenderOptions -from .bounds_configure import BoundsConfigure - -__all__ = [ - AxisDrawer, - MainDrawer, - Toolbar, - RenderOptions, - BoundsConfigure, -] diff --git a/pan3d/ui/axis_drawer.py b/pan3d/ui/axis_drawer.py deleted file mode 100644 index 3f47676f..00000000 --- a/pan3d/ui/axis_drawer.py +++ /dev/null @@ -1,118 +0,0 @@ -from trame.widgets import html -from trame.widgets import vuetify3 as vuetify -from .coordinate_configure import CoordinateConfigure - - -class AxisDrawer(vuetify.VNavigationDrawer): - def __init__( - self, - coordinate_select_axis_function, - coordinate_change_slice_function, - coordinate_toggle_expansion_function, - ui_axis_drawer="ui_axis_drawer", - ui_expanded_coordinates="ui_expanded_coordinates", - da_active="da_active", - da_coordinates="da_coordinates", - da_auto_slicing="da_auto_slicing", - da_x="da_x", - da_y="da_y", - da_z="da_z", - da_t="da_t", - da_t_index="da_t_index", - ): - super().__init__( - v_model=(ui_axis_drawer,), - classes="pa-2", - width="350", - location="right", - permanent=True, - style="position: absolute", - ) - axes = [ - { - "label": "X", - "name_var": da_x, - "index_var": "undefined", - }, - { - "label": "Y", - "name_var": da_y, - "index_var": "undefined", - }, - { - "label": "Z", - "name_var": da_z, - "index_var": "undefined", - }, - { - "label": "T", - "name_var": da_t, - "index_var": da_t_index, - }, - ] - with self: - with vuetify.VExpansionPanels( - model_value=("[0, 1]",), - multiple=True, - accordion=True, - v_if=(da_coordinates,), - ): - with vuetify.VExpansionPanel(title="Assigned Coordinates"): - with vuetify.VExpansionPanelText(): - for axis in axes: - with vuetify.VSheet(classes="d-flex"): - html.Span(axis["label"], classes="pt-2") - with html.Div( - v_show=(f"{axis['name_var']}",), style="width: 100%" - ): - CoordinateConfigure( - axes, - da_coordinates, - da_auto_slicing, - f"{da_coordinates}.find((c) => c.name === {axis['name_var']})", - ui_expanded_coordinates, - coordinate_select_axis_function, - coordinate_change_slice_function, - coordinate_toggle_expansion_function, - axis_info=axis, - ) - with vuetify.VCard( - v_show=(f"!{axis['name_var']}",), - height="45", - classes="ml-3 mb-1", - style="flex-grow: 1", - ): - vuetify.VCardSubtitle( - f"No coordinate assigned to {axis['label']}", - classes="text-center pt-3", - ) - - with vuetify.VExpansionPanel(title="Available Coordinates"): - with vuetify.VExpansionPanelText(): - with html.Div( - v_for=("coord in da_coordinates",), - v_show=( - f"![{da_x}, {da_y}, {da_z}, {da_t}].includes(coord.name)", - ), - ): - CoordinateConfigure( - axes, - da_coordinates, - da_auto_slicing, - "coord", - ui_expanded_coordinates, - coordinate_select_axis_function, - coordinate_change_slice_function, - coordinate_toggle_expansion_function, - ) - html.Span( - "No coordinates remain.", - v_show=( - f""" - da_coordinates.every( - (c) => [{da_x}, {da_y}, {da_z}, {da_t}].includes(c.name) - ) - """, - ), - classes="mx-5", - ) diff --git a/pan3d/ui/bounds_configure.py b/pan3d/ui/bounds_configure.py deleted file mode 100644 index 0326d4b4..00000000 --- a/pan3d/ui/bounds_configure.py +++ /dev/null @@ -1,137 +0,0 @@ -from trame.widgets import html -from trame.widgets import vuetify3 as vuetify - -from .pan3d_components.widgets import PreviewBounds - - -class BoundsConfigure(vuetify.VMenu): - def __init__( - self, - coordinate_change_bounds_function, - snap_camera_function, - da_coordinates="da_coordinates", - da_x="da_x", - da_y="da_y", - da_z="da_z", - da_t="da_t", - da_t_index="da_t_index", - cube_view_mode="cube_view_mode", - cube_preview="cube_preview", - cube_preview_face="cube_preview_face", - cube_preview_face_options="cube_preview_face_options", - cube_preview_axes="cube_preview_axes", - ui_bounds_menu="ui_bounds_menu", - ): - super().__init__( - v_model=(ui_bounds_menu,), - location="start", - transition="slide-x-transition", - close_on_content_click=False, - persistent=True, - no_click_animation=True, - ) - with self: - with vuetify.Template( - activator="{ props }", - __properties=[ - ("activator", "v-slot:activator"), - ], - ): - vuetify.VBtn( - v_bind=("props",), - size="small", - icon="mdi-tune-variant", - style="position: absolute; left: 20px; top: 60px; z-index:2", - ) - with vuetify.VCard(classes="pa-3", style="width: 325px"): - vuetify.VCardTitle("Configure Bounds") - vuetify.VCheckbox( - v_model=(cube_view_mode,), - label="Interactive Preview", - hide_details=True, - ) - vuetify.VSelect( - v_if=(cube_view_mode,), - v_model=(cube_preview_face,), - items=(cube_preview_face_options, []), - label="Face", - hide_details=True, - style="float:left", - ) - vuetify.VBtn( - v_if=(cube_view_mode,), - size="small", - icon="mdi-video-marker", - click=snap_camera_function, - style="float:right", - ) - with html.Div(v_if=(cube_view_mode,)): - PreviewBounds( - v_if=(cube_preview,), - preview=(cube_preview,), - axes=(cube_preview_axes,), - coordinates=(da_coordinates,), - update_bounds=( - coordinate_change_bounds_function, - "[$event.name, $event.bounds]", - ), - ) - with html.Div( - v_for=(f"coord in {da_coordinates}",), - ): - with html.Div( - v_if=( - f""" - ({da_x} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('X'))) || - ({da_y} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('Y'))) || - ({da_z} === coord.name && (!{cube_view_mode} || {cube_preview_face}.includes('Z'))) - """, - ), - style="text-transform: capitalize", - ): - html.Span("{{ coord.name.replaceAll('_', ' ') }}") - with vuetify.VRangeSlider( - model_value=("coord.bounds",), - strict=True, - hide_details=True, - step=1, - min=("coord.full_bounds[0]",), - max=("coord.full_bounds[1]",), - thumb_label=True, - classes=("coord.name +'-slider px-3'",), - end=( - coordinate_change_bounds_function, - "[coord.name, $event]", - ), - __events=[("end", "end")], - ): - with vuetify.Template( - v_slot_thumb_label=("{ modelValue }",) - ): - html.Span( - ("{{ coord.labels[modelValue] }}",), - style="white-space: nowrap", - ) - with html.Div( - v_for=(f"coord in {da_coordinates}",), - ): - with html.Div( - v_if=(f"{da_t} === coord.name",), - style="text-transform: capitalize", - ): - html.Span("{{ coord.name }}") - with vuetify.VSlider( - v_model=(da_t_index), - min=("coord.full_bounds[0]",), - max=("coord.full_bounds[1] - 1",), - step=1, - thumb_label=True, - classes="px-3", - ): - with vuetify.Template( - v_slot_thumb_label=("{ modelValue }",) - ): - html.Span( - ("{{ coord.labels[modelValue] }}",), - style="white-space: nowrap", - ) diff --git a/pan3d/ui/catalog_search.py b/pan3d/ui/catalog_search.py index b6fd89d7..d331e02b 100644 --- a/pan3d/ui/catalog_search.py +++ b/pan3d/ui/catalog_search.py @@ -16,15 +16,14 @@ def __init__( ui_catalog_search_message="ui_catalog_search_message", ): super().__init__(v_model=(ui_search_catalogs,), max_width=800) + + self.state.setdefault(ui_search_catalogs, False) + self.state.setdefault(available_catalogs, []) + self.state.setdefault(catalog_current_search, {}) + self.state.setdefault(ui_catalog_term_search_loading, False) + self.state.setdefault(ui_catalog_search_message, "") + with self: - with vuetify.Template(v_slot_activator=("{ props }",)): - vuetify.VBtn( - "Search Catalogs", - click=f"{ui_search_catalogs} = true", - size="small", - block=True, - classes="my-2", - ) with vuetify.VCard(): with vuetify.VCardText(): vuetify.VBtn( diff --git a/pan3d/ui/collapsible.py b/pan3d/ui/collapsible.py new file mode 100644 index 00000000..4aed3b6e --- /dev/null +++ b/pan3d/ui/collapsible.py @@ -0,0 +1,27 @@ +from trame.widgets import vuetify3 as v3 +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 v3.VCardSubtitle( + classes="px-0 ml-n3 d-flex align-center font-weight-bold pointer", + click=f"{show} = !{show}", + ) as container: + v3.VIcon( + f"{{{{ {show} ? 'mdi-menu-down' : 'mdi-menu-right' }}}}", + size="sm", + classes="pa-0 ma-0", + ) + container.add_child(title) + self.content = v3.VSheet( + border="opacity-25 thin", + classes="overflow-hidden mx-auto mt-1 mb-2", + rounded="lg", + v_show=(show, expended), + ) diff --git a/pan3d/ui/coordinate_configure.py b/pan3d/ui/coordinate_configure.py deleted file mode 100644 index 86788b8f..00000000 --- a/pan3d/ui/coordinate_configure.py +++ /dev/null @@ -1,164 +0,0 @@ -from trame.widgets import html, vuetify3 as vuetify - - -class CoordinateConfigure(vuetify.VCard): - def __init__( - self, - axes, - da_coordinates, - da_auto_slicing, - coordinate_info, - ui_expanded_coordinates, - coordinate_select_axis_function, - coordinate_change_slice_function, - coordinate_toggle_expansion_function, - axis_info=None, # Defined only after an axis is selected for this coord - ): - super().__init__(width="95%", classes="ml-3 mb-1") - - with self: - with vuetify.VExpansionPanels( - model_value=(ui_expanded_coordinates, []), - accordion=True, - multiple=True, - v_show=(coordinate_info,), - update_modelValue=( - coordinate_toggle_expansion_function, - f"[{coordinate_info}.name]", - ), - ): - with vuetify.VExpansionPanel(value=(f"{coordinate_info}?.name",)): - vuetify.VExpansionPanelTitle("{{ %s?.name }}" % coordinate_info) - with vuetify.VExpansionPanelText(): - vuetify.VCardSubtitle("Attributes") - with vuetify.VTable( - density="compact", v_show=(coordinate_info,) - ): - with html.Tbody(): - with html.Tr( - v_for=(f"data_attr in {coordinate_info}?.attrs",), - ): - html.Td("{{ data_attr.key }}") - html.Td("{{ data_attr.value }}") - - if axis_info and axis_info["index_var"] != "undefined": - vuetify.VCardSubtitle( - "Current: {{ %s?.labels[%s] }}" - % (coordinate_info, axis_info["index_var"]), - classes="mt-3", - ) - with vuetify.VSlider( - v_model=(axis_info["index_var"],), - min=0, - max=(f"{coordinate_info}?.labels.length - 1",), - step=1, - classes="mx-5", - ): - with vuetify.Template( - v_slot_append=("{ props, item, parent }",) - ): - vuetify.VTextField( - v_model=(axis_info["index_var"],), - min=0, - max=(f"{coordinate_info}?.labels.length - 1",), - step=1, - hide_details=True, - density="compact", - style="width: 120px", - classes="ml-3", - type="number", - __properties=["min", "max"], - ) - - else: - vuetify.VCardSubtitle( - "Select slicing", - classes="mt-3", - ) - with vuetify.VContainer( - classes="d-flex pa-0", - style="column-gap: 3px", - ): - vuetify.VTextField( - model_value=(f"{coordinate_info}?.bounds[0]",), - label="Start", - hide_details=True, - density="compact", - type="number", - min=(f"{coordinate_info}.full_bounds[0]",), - max=(f"{coordinate_info}.full_bounds[1]",), - step="1", - __properties=["min", "max", "step"], - update=( - coordinate_change_slice_function, - f"[{coordinate_info}.name, 'start', $event]", - ), - __events=[("update", "update:modelValue")], - style="flex-grow: 1", - ) - vuetify.VTextField( - model_value=(f"{coordinate_info}?.bounds[1]",), - label="Stop", - hide_details=True, - density="compact", - type="number", - min=(f"{coordinate_info}.full_bounds[0]",), - max=(f"{coordinate_info}.full_bounds[1]",), - step="1", - __properties=["min", "max", "step"], - update=( - coordinate_change_slice_function, - f"[{coordinate_info}.name, 'stop', $event]", - ), - __events=[("update", "update:modelValue")], - style="flex-grow: 1", - ) - vuetify.VTextField( - model_value=(f"{coordinate_info}?.step",), - label="Step", - disabled=(da_auto_slicing,), - hide_details=True, - density="compact", - type="number", - min="1", - max=(f"{coordinate_info}?.length",), - step="1", - __properties=["min", "max", "step"], - update=( - coordinate_change_slice_function, - f"[{coordinate_info}.name, 'step', $event]", - ), - __events=[("update", "update:modelValue")], - style="flex-grow: 1", - ) - - vuetify.VCardSubtitle("Assign axis", classes="mt-3") - with vuetify.VSelect( - items=(str(axes),), - item_title="label", - item_value="name_var", - model_value=(str(axis_info) or "undefined",), - clearable=True, - click_clear=( - ( - coordinate_select_axis_function, - # args: coord name, current axis, new axis - f"[{axis_info['name_var']}, '{axis_info['name_var']}', 'undefined']", - ) - if axis_info - else "undefined" - ), - update_modelValue=( - coordinate_select_axis_function, - # args: coord name, current axis, new axis - f"""[ - {coordinate_info}.name, - '{axis_info["name_var"] if axis_info else "undefined"}', - $event - ]""", - ), - ): - with vuetify.Template( - v_slot_selection=("{ props, item, parent }",) - ): - html.Span(axis_info["label"] if axis_info else "") diff --git a/pan3d/ui/css/__init__.py b/pan3d/ui/css/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pan3d/ui/css/base.py b/pan3d/ui/css/base.py new file mode 100644 index 00000000..dece8473 --- /dev/null +++ b/pan3d/ui/css/base.py @@ -0,0 +1,4 @@ +from pathlib import Path + +serve_path = str(Path(__file__).parent.resolve()) +serve = {"__pan3d_css": serve_path} diff --git a/pan3d/ui/css/preview.css b/pan3d/ui/css/preview.css new file mode 100644 index 00000000..92b9f6f8 --- /dev/null +++ b/pan3d/ui/css/preview.css @@ -0,0 +1,31 @@ +.summary-toolbar { + position: absolute; + top: 1rem; + left: 5rem; + right: 5rem; + height: 2.4rem; + z-index: 2; + display: flex; + align-items: center; +} + +.controller { + position: absolute; + top: 1rem; + left: 1rem; + max-height: calc(100vh - 2rem); + overflow: auto; + z-index: 2; +} + +.controller-content { + width: clamp(20rem, 25vw, 30rem); +} + +.pointer { + cursor: pointer; +} + +.no-bg { + background: none; +} \ No newline at end of file diff --git a/pan3d/ui/css/preview.py b/pan3d/ui/css/preview.py new file mode 100644 index 00000000..82cad970 --- /dev/null +++ b/pan3d/ui/css/preview.py @@ -0,0 +1 @@ +styles = ["__pan3d_css/preview.css"] diff --git a/pan3d/ui/css/vtk_view.css b/pan3d/ui/css/vtk_view.css new file mode 100644 index 00000000..862a067b --- /dev/null +++ b/pan3d/ui/css/vtk_view.css @@ -0,0 +1,60 @@ +.pan3d-view { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.view-lock { + position:absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +.view-toolbar { + position: absolute; + top: 1rem; + right: 1rem; + opacity: 0.8; + display: flex; + flex-direction: column; + z-index: 2; +} + +.scalarbar { + position: absolute; + bottom: 1rem; + left: 15rem; + right: 15rem; + height: 2rem; + z-index: 2; +} + +.scalarbar-left { + position: absolute; + left: 0; + z-index: 2; + top: 1rem; + transform: translateY(-50%) translateX(-110%); +} +.scalarbar-right { + position: absolute; + right: 0; + z-index: 2; + top: 1rem; + transform: translateY(-50%) translateX(110%); +} +.scalar-cursor { + position: absolute; + z-index: 1; + width: 3px; + background: rgb(255,255,255); + background: linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 50%, rgba(255,255,255,1) 100%); + top: -5px; + bottom: -5px; + pointer-events: none; +} \ No newline at end of file diff --git a/pan3d/ui/css/vtk_view.py b/pan3d/ui/css/vtk_view.py new file mode 100644 index 00000000..34e003e0 --- /dev/null +++ b/pan3d/ui/css/vtk_view.py @@ -0,0 +1 @@ +styles = ["__pan3d_css/vtk_view.css"] diff --git a/pan3d/ui/custom.css b/pan3d/ui/custom.css deleted file mode 100644 index ee35b3fc..00000000 --- a/pan3d/ui/custom.css +++ /dev/null @@ -1,27 +0,0 @@ -.v-expansion-panel-text__wrapper { - padding: 5px !important; -} - -.v-expansion-panel--active>.v-expansion-panel-title { - min-height: 30px !important; -} - -.v-input__prepend { - margin-inline-end: 0px !important; -} - -.v-input__append { - margin-inline-start: 0px !important; -} - -.v-selection-control__input>.v-icon { - opacity: 100% !important; -} - -.v-col { - padding: 0px !important -} - -html { - overflow-y: hidden -} diff --git a/pan3d/ui/main_drawer.py b/pan3d/ui/main_drawer.py deleted file mode 100644 index a5141794..00000000 --- a/pan3d/ui/main_drawer.py +++ /dev/null @@ -1,161 +0,0 @@ -from trame.widgets import html -from trame.widgets import vuetify3 as vuetify - -from .catalog_search import CatalogSearch - - -class MainDrawer(vuetify.VNavigationDrawer): - def __init__( - self, - update_catalog_search_term_function, - catalog_search_function, - catalog_term_search_function, - switch_data_group_function, - available_catalogs="available_catalogs", - available_data_groups="available_data_groups", - data_group="data_group", - dataset_ready="dataset_ready", - dataset_info="dataset_info", - da_attrs="da_attrs", - da_vars="da_vars", - da_vars_attrs="da_vars_attrs", - available_datasets="available_datasets", - ui_main_drawer="ui_main_drawer", - ui_more_info_link="ui_more_info_link", - ui_group_loading="ui_group_loading", - da_active="da_active", - da_x="da_x", - da_y="da_y", - da_z="da_z", - da_t="da_t", - da_t_index="da_t_index", - ): - super().__init__( - v_model=(ui_main_drawer,), classes="pa-2", permanent=True, width=300 - ) - with self: - with html.Div( - v_show=(f"{available_catalogs}.length",), - ): - CatalogSearch( - update_catalog_search_term_function, - catalog_search_function, - catalog_term_search_function, - ) - vuetify.VSelect( - label="Choose a group", - v_show=(available_data_groups,), - items=(available_data_groups, []), - v_model=(data_group,), - loading=(ui_group_loading,), - disabled=(ui_group_loading,), - item_title="name", - item_value="value", - density="compact", - hide_details=True, - update_modelValue=switch_data_group_function, - ) - vuetify.VSelect( - label="Choose a dataset", - v_show=(data_group,), - v_model=(dataset_info,), - items=(f"{available_datasets}[{data_group}]", []), - loading=(ui_group_loading,), - disabled=(ui_group_loading,), - item_title="name", - item_value="value", - density="compact", - hide_details=True, - ) - - with vuetify.VListItem(v_show=(dataset_ready,)): - with html.Div( - classes="d-flex pa-2", style="justify-content: space-between" - ): - html.Span("Attributes") - with vuetify.VDialog(max_width=800): - with vuetify.Template(v_slot_activator=("{ props }",)): - vuetify.VBtn( - icon="mdi-dots-horizontal", - size="x-small", - variant="plain", - v_bind=("props",), - ) - with vuetify.VCard(): - vuetify.VCardTitle( - "Dataset Attributes", - v_show=(f"{da_attrs}.length",), - classes="font-weight-bold", - ) - vuetify.VCardText( - "No attributes.", - v_show=(f"{da_attrs}.length === 0",), - ) - with vuetify.VTable( - v_show=(f"{dataset_ready} && {da_attrs}.length",), - density="compact", - ): - with html.Tbody(): - with html.Tr( - v_for=(f"data_attr in {da_attrs}",), - ): - html.Td("{{ data_attr.key }}") - html.Td("{{ data_attr.value }}") - - html.A( - "More information about this dataset", - href=(ui_more_info_link,), - v_show=(ui_more_info_link,), - target="_blank", - classes="mx-3", - ) - - vuetify.VDivider(v_show=(dataset_ready,), classes="my-2") - - vuetify.VCardText( - "Available Arrays", - v_show=(dataset_ready,), - classes="font-weight-bold", - ) - vuetify.VCardText( - "No data variables found.", - v_show=(f"{dataset_ready} && {da_vars}.length === 0",), - ) - with vuetify.VListItem( - v_for=(f"array in {da_vars}",), - active=(f"array.name === {da_active}",), - click=f"{da_active} = array.name", - ): - with html.Div( - classes="d-flex pa-2", style="justify-content: space-between" - ): - html.Span("{{ array.name }}") - with vuetify.VDialog(max_width=800): - with vuetify.Template(v_slot_activator=("{ props }",)): - vuetify.VBtn( - icon="mdi-dots-horizontal", - size="x-small", - variant="plain", - v_bind=("props",), - ) - with vuetify.VCard(): - vuetify.VCardTitle( - "Array Attributes", - classes="font-weight-bold", - ) - vuetify.VCardText( - "No attributes.", - v_show=(f"{da_vars_attrs}[array.name].length === 0",), - ) - with vuetify.VTable( - v_show=(f"{da_vars_attrs}[array.name].length",), - density="compact", - ): - with html.Tbody(): - with html.Tr( - v_for=( - f"data_attr in {da_vars_attrs}[array.name]", - ), - ): - html.Td("{{ data_attr.key }}") - html.Td("{{ data_attr.value }}") diff --git a/pan3d/ui/pan3d_components/module/__init__.py b/pan3d/ui/pan3d_components/module/__init__.py deleted file mode 100644 index 01ea56bc..00000000 --- a/pan3d/ui/pan3d_components/module/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - -# Compute local path to serve -serve_path = str(Path(__file__).with_name("serve").resolve()) - -# Serve directory for JS/CSS files -serve = {"__pan3d_components": serve_path} - -# List of JS files to load (usually from the serve path above) -scripts = ["__pan3d_components/pan3d-components.umd.js"] - -# List of Vue plugins to install/load -vue_use = ["pan3d_components"] diff --git a/pan3d/ui/pan3d_components/widgets.py b/pan3d/ui/pan3d_components/widgets.py deleted file mode 100644 index a95a0740..00000000 --- a/pan3d/ui/pan3d_components/widgets.py +++ /dev/null @@ -1,26 +0,0 @@ -from trame_client.widgets.core import AbstractElement -from . import module - -__all__ = [ - "PreviewBounds", -] - - -class HtmlElement(AbstractElement): - def __init__(self, _elem_name, children=None, **kwargs): - super().__init__(_elem_name, children, **kwargs) - if self.server: - self.server.enable_module(module) - - -class PreviewBounds(HtmlElement): - def __init__(self, children=None, **kwargs): - super().__init__("preview-bounds", children, **kwargs) - self._attr_names += [ - "preview", - "axes", - "coordinates", - ] - self._event_names += [ - ("update_bounds", "update-bounds"), - ] diff --git a/pan3d/ui/preview.py b/pan3d/ui/preview.py new file mode 100644 index 00000000..81ae423a --- /dev/null +++ b/pan3d/ui/preview.py @@ -0,0 +1,941 @@ +import math +from pathlib import Path + +from trame.decorators import TrameApp, change +from trame.widgets import html, vuetify3 as v3 + +from pan3d import catalogs as pan3d_catalogs +from pan3d.utils.constants import XYZ, SLICE_VARS +from pan3d.utils.convert import max_str_length +from pan3d.utils.presets import COLOR_PRESETS + +from pan3d.ui.css import base, preview +from pan3d.ui.collapsible import CollapsableSection + + +class SummaryToolbar(v3.VCard): + def __init__( + self, + t_labels="t_labels", + slice_t="slice_t", + slice_t_max="slice_t_max", + color_by="color_by", + data_arrays="data_arrays", + max_time_width="max_time_width", + max_time_index_width="max_time_index_width", + **kwargs, + ): + super().__init__( + classes="summary-toolbar", + rounded="pill", + **kwargs, + ) + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(preview) + + with self: + with v3.VToolbar( + classes="pl-2", + height=50, + elevation=1, + style="background: none;", + ): + v3.VIcon("mdi-clock-outline") + html.Pre( + f"{{{{ {t_labels}[slice_t] }}}}", + classes="mx-2 text-left", + style=(f"`min-width: ${{ {max_time_width} }}rem;`",), + ) + v3.VSlider( + prepend_inner_icon="mdi-clock-outline", + v_model=(slice_t, 0), + min=0, + max=(slice_t_max, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + classes="mx-2", + ) + html.Div( + f"{{{{ {slice_t} + 1 }}}}/{{{{ {slice_t_max} + 1 }}}}", + classes="mx-2 text-right", + style=(f"`min-width: ${{ {max_time_index_width} }}rem;`",), + ) + v3.VSelect( + placeholder="Color By", + prepend_inner_icon="mdi-format-color-fill", + v_model=(color_by, None), + items=(data_arrays, []), + clearable=True, + hide_details=True, + density="compact", + flat=True, + variant="solo", + max_width=200, + ) + + +class DataOrigin(CollapsableSection): + def __init__(self, load_dataset): + super().__init__("Data origin", "show_data_origin", True) + + self.state.load_button_text = "Load" + self.state.can_load = True + self.state.data_origin_id_to_desc = {} + + with self.content: + v3.VSelect( + label="Source", + v_model=("data_origin_source", "xarray"), + items=( + "data_origin_sources", + pan3d_catalogs.list_availables(), + ), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VTextField( + placeholder="Location", + v_if="['file', 'url'].includes(data_origin_source)", + v_model=("data_origin_id", ""), + hide_details=True, + density="compact", + flat=True, + variant="solo", + append_inner_icon=( + "data_origin_id_error ? 'mdi-file-document-alert-outline' : undefined", + ), + error=("data_origin_id_error", False), + ) + + with v3.VTooltip( + v_else=True, + text=("`${ data_origin_id_to_desc[data_origin_id] }`",), + ): + with html.Template(v_slot_activator="{ props }"): + v3.VSelect( + v_bind="props", + label="Name", + v_model="data_origin_id", + items=("data_origin_ids", []), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + + # v3.VDivider() + # v3.VSwitch( + # label=("`Order ${data_origin_order}`",), + # v_model=("data_origin_order", "C"), + # true_value="C", + # false_value="F", + # hide_details=True, + # density="compact", + # flat=True, + # variant="solo", + # classes="mx-6", + # ) + v3.VDivider() + v3.VBtn( + "{{ load_button_text }}", + block=True, + classes="text-none", + flat=True, + density="compact", + rounded=0, + disabled=("!data_origin_id?.length || !can_load",), + color=("can_load ? 'primary': undefined",), + click=( + load_dataset, + "[data_origin_source, data_origin_id, data_origin_order]", + ), + ) + + +class DataInformation(CollapsableSection): + def __init__(self, xarray_info="xarray_info"): + super().__init__("Data information", "show_data_information") + + self._var_name = xarray_info + self.state.setdefault(xarray_info, []) + + with self.content: + with v3.VTable(density="compact", hover=True): + with html.Tbody(): + with html.Template(v_for=f"item, i in {xarray_info}", key="i"): + with v3.VTooltip(): + with html.Template(v_slot_activator="{ props }"): + with html.Tr(v_bind="props", classes="pointer"): + with html.Td( + classes="d-flex align-center text-no-wrap" + ): + v3.VIcon( + "{{ item.icon }}", + size="sm", + classes="mr-2", + ) + html.Div("{{ item.name }}") + html.Td( + "{{ item.length }}", + classes="text-right", + ) + + with v3.VTable( + density="compact", + theme="dark", + classes="no-bg ma-0 pa-0", + ): + with html.Tbody(): + with html.Tr( + v_for="attr, j in item.attrs", + key="j", + ): + html.Td( + "{{ attr.key }}", + ) + html.Td( + "{{ attr.value }}", + ) + + def update_information(self, xr, available_arrays=None): + xarray_info = [] + coords = set(xr.coords.keys()) + data = set(available_arrays or []) + for name in xr.variables: + icon = "mdi-variable" + order = 3 + length = f'({",".join(xr[name].dims)})' + attrs = [] + if name in coords: + icon = "mdi-ruler" + order = 1 + length = xr[name].size + shape = xr[name].shape + if length > 1 and len(shape) == 1: + attrs.append( + { + "key": "range", + "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", + } + ) + if name in data: + icon = "mdi-database" + order = 2 + xarray_info.append( + { + "order": order, + "icon": icon, + "name": name, + "length": length, + "type": str(xr[name].dtype), + "attrs": attrs + + [ + {"key": "type", "value": str(xr[name].dtype)}, + ] + + [ + {"key": str(k), "value": str(v)} + for k, v in xr[name].attrs.items() + ], + } + ) + xarray_info.sort(key=lambda item: item["order"]) + + # Update UI + self.state[self._var_name] = xarray_info + + +@TrameApp() +class RenderingSettings(CollapsableSection): + def __init__(self, source, update_rendering): + super().__init__("Rendering", "show_rendering") + + self.source = source + self.state.setdefault("slice_extents", {}) + self.state.setdefault("axis_names", []) + self.state.setdefault("t_labels", []) + self.state.setdefault("max_time_width", 0) + self.state.setdefault("max_time_index_width", 0) + self.state.setdefault("dataset_bounds", [0, 1, 0, 1, 0, 1]) + + with self.content: + v3.VSelect( + placeholder="Data arrays", + prepend_inner_icon="mdi-database", + hide_selected=True, + v_model=("data_arrays", []), + items=("data_arrays_available", []), + multiple=True, + hide_details=True, + density="compact", + chips=True, + closable_chips=True, + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VSelect( + placeholder="Color By", + prepend_inner_icon="mdi-format-color-fill", + v_model=("color_by", None), + items=("data_arrays", []), + clearable=True, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + with v3.VRow(no_gutters=True, classes="align-center mr-0"): + with v3.VCol(): + v3.VTextField( + prepend_inner_icon="mdi-water-minus", + v_model_number=("color_min", 0.45), + type="number", + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + ) + with v3.VCol(): + v3.VTextField( + prepend_inner_icon="mdi-water-plus", + v_model_number=("color_max", 5.45), + type="number", + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + ) + with html.Div(classes="flex-0"): + v3.VBtn( + icon="mdi-arrow-split-vertical", + size="sm", + density="compact", + flat=True, + variant="outlined", + classes="mx-2", + click=self.reset_color_range, + ) + # v3.VDivider() + html.Img( + src=("preset_img", None), + style="height: 0.75rem; width: 100%;", + classes="rounded-lg border-thin", + ) + v3.VSelect( + placeholder="Color Preset", + prepend_inner_icon="mdi-palette", + v_model=("color_preset", "Cool to Warm"), + items=("color_presets", COLOR_PRESETS), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + + with v3.VTooltip( + text=("`NaN Color (${nan_colors[nan_color]})`",), + ): + with html.Template(v_slot_activator="{ props }"): + with v3.VItemGroup( + v_model="nan_color", + v_bind="props", + classes="d-inline-flex ga-1 pa-2", + mandatory="force", + ): + v3.VIcon( + "mdi-eyedropper-variant", + classes="my-auto mx-1 text-medium-emphasis", + ) + with v3.VItem( + v_for="(color, i) in nan_colors", key="i", value=("i",) + ): + with v3.Template( + raw_attrs=['#default="{ isSelected, toggle }"'] + ): + with v3.VAvatar( + density="compact", + color=("isSelected ? 'primary': 'transparent'",), + ): + v3.VBtn( + "{{ color[3] < 0.1 ? 't' : '' }}", + density="compact", + border="md surface opacity-100", + color=( + "color[3] ? `rgb(${color[0] * 255}, ${color[1] * 255}, ${color[2] * 255})` : undefined", + ), + flat=True, + icon=True, + ripple=False, + size="small", + click="toggle", + ) + + v3.VDivider() + # X crop/cut + with v3.VTooltip( + v_if="axis_names?.[0]", + text=( + "`${axis_names[0]}: [${dataset_bounds[0]}, ${dataset_bounds[1]}] ${slice_x_type ==='range' ? ('(' + slice_x_range.map((v,i) => v+1).concat(slice_x_step).join(', ') + ')'): slice_x_cut}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if="axis_names?.[0]", + v_bind="props", + ): + v3.VRangeSlider( + v_if="slice_x_type === 'range'", + prepend_icon="mdi-axis-x-arrow", + v_model=("slice_x_range", [0, 1]), + min=("slice_extents[axis_names[0]][0]",), + max=("slice_extents[axis_names[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-x-arrow", + v_model=("slice_x_cut", 0), + min=("slice_extents[axis_names[0]][0]",), + max=("slice_extents[axis_names[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=("slice_x_type", "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Y crop/cut + with v3.VTooltip( + v_if="axis_names?.[1]", + text=( + "`${axis_names[1]}: [${dataset_bounds[2]}, ${dataset_bounds[3]}] ${slice_y_type ==='range' ? ('(' + slice_y_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_y_cut}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if="axis_names?.[1]", + v_bind="props", + ): + v3.VRangeSlider( + v_if="slice_y_type === 'range'", + prepend_icon="mdi-axis-y-arrow", + v_model=("slice_y_range", [0, 1]), + min=("slice_extents[axis_names[1]][0]",), + max=("slice_extents[axis_names[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-y-arrow", + v_model=("slice_y_cut", 0), + min=("slice_extents[axis_names[1]][0]",), + max=("slice_extents[axis_names[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=("slice_y_type", "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Z crop/cut + with v3.VTooltip( + v_if="axis_names?.[2]", + text=( + "`${axis_names[2]}: [${dataset_bounds[4]}, ${dataset_bounds[5]}] ${slice_z_type ==='range' ? ('(' + slice_z_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_z_cut}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_bind="props", + ): + v3.VRangeSlider( + v_if="slice_z_type === 'range'", + prepend_icon="mdi-axis-z-arrow", + v_model=("slice_z_range", [0, 1]), + min=("slice_extents[axis_names[2]][0]",), + max=("slice_extents[axis_names[2]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-z-arrow", + v_model=("slice_z_cut", 0), + min=("slice_extents[axis_names[2]][0]",), + max=("slice_extents[axis_names[2]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=("slice_z_type", "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + v3.VDivider() + + # Slice steps + with v3.VTooltip(text="Level Of Details / Slice stepping"): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutter=True, + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + "mdi-stairs", + classes="ml-2 text-medium-emphasis", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): + v3.VTextField( + v_model_number=("slice_x_step", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=['min="1"'], + type="number", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): + v3.VTextField( + v_model_number=("slice_y_step", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=['min="1"'], + type="number", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): + v3.VTextField( + v_model_number=("slice_z_step", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=['min="1"'], + type="number", + ) + + # Actor scaling + with v3.VTooltip(text="Representation scaling"): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutter=True, + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + "mdi-ruler-square", + classes="ml-2 text-medium-emphasis", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): + v3.VTextField( + v_model=("scale_x", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', # noqa: W605 + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): + v3.VTextField( + v_model=("scale_y", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', # noqa: W605 + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) + with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): + v3.VTextField( + v_model=("scale_z", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', # noqa: W605 + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) + + # Time slider + with v3.VTooltip( + v_if="slice_t_max > 0", + text=("`time: ${t_labels[slice_t]} (${slice_t+1}/${slice_t_max+1})`",), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex pr-2", + v_bind="props", + ): + v3.VSlider( + prepend_icon="mdi-clock-outline", + v_model=("slice_t", 0), + min=0, + max=("slice_t_max", 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VBtn( + "Update 3D view", + block=True, + classes="text-none", + flat=True, + density="compact", + rounded=0, + disabled=("data_arrays.length === 0",), + color=("dirty_data && data_arrays.length ? 'primary': undefined",), + click=(update_rendering, "[true]"), + ) + + def update_from_source(self, source=None): + if source is None: + source = self.source + + with self.state: + self.state.data_arrays_available = source.available_arrays + self.state.data_arrays = source.arrays + self.state.color_by = None + self.state.axis_names = [source.x, source.y, source.z] + self.state.slice_extents = source.slice_extents + slices = source.slices + for axis in XYZ: + # default + axis_extent = self.state.slice_extents.get(getattr(source, axis)) + self.state[f"slice_{axis}_range"] = axis_extent + self.state[f"slice_{axis}_cut"] = 0 + self.state[f"slice_{axis}_step"] = 1 + self.state[f"slice_{axis}_type"] = "range" + + # use slice info if available + axis_slice = slices.get(getattr(source, axis)) + if axis_slice is not None: + if isinstance(axis_slice, int): + # cut + self.state[f"slice_{axis}_cut"] = axis_slice + self.state[f"slice_{axis}_type"] = "cut" + else: + # range + self.state[f"slice_{axis}_range"] = [ + axis_slice[0], + axis_slice[1] - 1, + ] # end is inclusive + self.state[f"slice_{axis}_step"] = axis_slice[2] + + # Update time + self.state.slice_t = source.t_index + self.state.slice_t_max = source.t_size - 1 + self.state.t_labels = source.t_labels + self.state.max_time_width = math.ceil( + 0.58 * max_str_length(self.state.t_labels) + ) + if self.state.slice_t_max > 0: + self.state.max_time_index_width = math.ceil( + 0.6 + (math.log10(self.state.slice_t_max + 1) + 1) * 2 * 0.58 + ) + + def reset_color_range(self): + color_by = self.state.color_by + ds = self.source() + if color_by in ds.point_data.keys(): + array = ds.point_data[color_by] + min_value, max_value = array.GetRange() + + self.state.color_min = min_value + self.state.color_max = max_value + else: + self.state.color_min = 0 + self.state.color_max = 1 + + @change("data_origin_source") + def _on_data_origin_source(self, data_origin_source, **kwargs): + if self.state.import_pending: + return + + self.state.data_origin_id = "" + results, *_ = pan3d_catalogs.search(data_origin_source) + self.state.data_origin_ids = [v["name"] for v in results] + self.state.data_origin_id_to_desc = { + v["name"]: v["description"] for v in results + } + + @change("data_origin_id") + def _on_data_origin_id(self, data_origin_id, data_origin_source, **kwargs): + if self.state.import_pending: + return + + self.state.load_button_text = "Load" + self.state.can_load = True + + if data_origin_source == "file": + self.state.data_origin_id_error = not Path(data_origin_id).exists() + elif self.state.data_origin_id_error: + self.state.data_origin_id_error = False + + @change("slice_t", *[var.format(axis) for axis in XYZ for var in SLICE_VARS]) + def on_change(self, slice_t, **_): + if self.state.import_pending: + return + + slices = {self.source.t: slice_t} + for axis in XYZ: + axis_name = getattr(self.source, axis) + if axis_name is None: + continue + + if self.state[f"slice_{axis}_type"] == "range": + slices[axis_name] = [ + *self.state[f"slice_{axis}_range"], + int(self.state[f"slice_{axis}_step"]), + ] + slices[axis_name][1] += 1 # end is exclusive + else: + slices[axis_name] = self.state[f"slice_{axis}_cut"] + + self.source.slices = slices + ds = self.source() + self.state.dataset_bounds = ds.bounds + + if self.state.disable_rendering: + return + + self.ctrl.view_reset_clipping_range() + self.ctrl.view_update() + + @change("slice_t") + def _on_slice_t(self, slice_t, **_): + if self.state.import_pending: + return + + self.source.t_index = slice_t + + if self.state.disable_rendering: + return + + self.ctrl.view_update() + + @change("data_arrays") + def _on_array_selection(self, data_arrays, **_): + if self.state.import_pending: + return + + self.state.dirty_data = True + if len(data_arrays) == 1: + self.state.color_by = data_arrays[0] + elif len(data_arrays) == 0: + self.state.color_by = None + + self.source.arrays = data_arrays + + +class ControlPanel(v3.VCard): + def __init__( + self, + source, + toggle, + load_dataset, + update_rendering, + import_file_upload, + export_file_download, + xr_update_info="xr_update_info", + source_update_rendering="source_update_rendering", + **kwargs, + ): + super().__init__( + classes="controller", + rounded=(f"{toggle} || 'circle'",), + **kwargs, + ) + + # state initialization + self.state.import_pending = False + + # extract trigger name + download_export = self.ctrl.trigger_name(export_file_download) + + with self: + with v3.VCardTitle( + classes=( + f"`d-flex pa-1 position-fixed bg-white ${{ {toggle} ? 'controller-content rounded-t border-b-thin':'rounded-circle'}}`", + ), + style="z-index: 1;", + ): + v3.VProgressLinear( + v_if=toggle, + indeterminate=("trame__busy",), + bg_color="rgba(0,0,0,0)", + absolute=True, + color="primary", + location="bottom", + height=2, + ) + v3.VProgressCircular( + v_else=True, + bg_color="rgba(0,0,0,0)", + indeterminate=("trame__busy",), + style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;", + color="primary", + width=3, + ) + v3.VBtn( + icon="mdi-close", + v_if=toggle, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + v3.VBtn( + icon="mdi-menu", + v_else=True, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + if self.server.hot_reload: + v3.VBtn( + v_show=toggle, + icon="mdi-refresh", + flat=True, + size="sm", + click=self.ctrl.on_server_reload, + ) + v3.VSpacer() + html.Div( + "XArray Viewer", + v_show=toggle, + classes="text-h6 px-2", + ) + v3.VSpacer() + + with v3.VMenu(v_if=toggle, density="compact"): + with html.Template(v_slot_activator="{props}"): + v3.VBtn( + v_bind="props", + icon="mdi-file-arrow-left-right-outline", + flat=True, + size="sm", + classes="mx-1", + ) + with v3.VList(density="compact"): + with v3.VListItem( + title="Export state file", + disabled=("can_load",), + click=f"utils.download('xarray-state.json', trigger('{download_export}'), 'text/plain')", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-cloud-download-outline", classes="mr-n5") + + with v3.VListItem( + title="Import state file", + click="trame.utils.get('document').querySelector('#fileImport').click()", + ): + html.Input( + id="fileImport", + hidden=True, + type="file", + change=( + import_file_upload, + "[$event.target.files]", + ), + __events=["change"], + ) + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-cloud-upload-outline", classes="mr-n5") + v3.VDivider() + with v3.VListItem( + title="Save dataset to disk", + disabled=("can_load",), + click="show_save_dialog = true", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-file-download-outline", classes="mr-n5") + + with v3.VCardText( + v_show=(toggle, True), + classes="controller-content py-1 mt-10", + ): + DataOrigin(load_dataset) + self.ctrl[xr_update_info] = DataInformation().update_information + self.ctrl[source_update_rendering] = RenderingSettings( + source, + update_rendering, + ).update_from_source diff --git a/pan3d/ui/render_options.py b/pan3d/ui/render_options.py deleted file mode 100644 index 251a87cd..00000000 --- a/pan3d/ui/render_options.py +++ /dev/null @@ -1,103 +0,0 @@ -from trame.widgets import vuetify3 as vuetify - - -class RenderOptions(vuetify.VMenu): - def __init__( - self, - x_scale="render_x_scale", - y_scale="render_y_scale", - z_scale="render_z_scale", - colormap="render_colormap", - colormap_options="render_colormap_options", - transparency="render_transparency", - transparency_function="render_transparency_function", - transparency_function_options="render_transparency_function_options", - scalar_warp="render_scalar_warp", - cartographic="render_cartographic", - ui_render_options_menu="ui_render_options_menu", - ): - super().__init__( - v_model=(ui_render_options_menu,), - location="start", - transition="slide-x-transition", - close_on_content_click=False, - persistent=True, - no_click_animation=True, - ) - with self: - with vuetify.Template( - activator="{ props }", - __properties=[ - ("activator", "v-slot:activator"), - ], - ): - vuetify.VBtn( - v_bind=("props",), - size="small", - icon="mdi-cog", - style="position: absolute; right: 20px; top: 20px; z-index:2", - ) - with vuetify.VCard(classes="pa-3"): - vuetify.VSelect( - label="Colormap", - v_model=(colormap,), - items=(colormap_options,), - density="compact", - ) - vuetify.VCheckbox( - label="Transparency", v_model=(transparency,), density="compact" - ) - vuetify.VSelect( - label="Transparency Function", - v_show=(transparency,), - v_model=(transparency_function,), - items=(transparency_function_options,), - density="compact", - ) - # Scalar warp mode and cartographic mode are mutually exclusive - vuetify.VCheckbox( - label="Warp by Scalars", - v_model=(scalar_warp,), - disabled=(cartographic,), - density="compact", - ) - vuetify.VCheckbox( - label="Cartographic", - v_model=(cartographic,), - disabled=(scalar_warp,), - density="compact", - ) - with vuetify.VContainer(classes="d-flex pa-0", style="column-gap: 3px"): - vuetify.VTextField( - v_model=(x_scale,), - label="X Scale", - min=1, - step=1, - hide_details=True, - density="compact", - style="width: 80px", - type="number", - __properties=["min", "step"], - ) - vuetify.VTextField( - v_model=(y_scale,), - label="Y Scale", - min=1, - step=1, - hide_details=True, - density="compact", - style="width: 80px", - type="number", - __properties=["min", "step"], - ) - vuetify.VTextField( - v_model=(z_scale,), - label="Z Scale", - min=1, - step=1, - hide_details=True, - density="compact", - style="width: 80px", - type="number", - __properties=["min", "step"], - ) diff --git a/pan3d/ui/toolbar.py b/pan3d/ui/toolbar.py deleted file mode 100644 index edab3459..00000000 --- a/pan3d/ui/toolbar.py +++ /dev/null @@ -1,81 +0,0 @@ -from trame.widgets import html -from trame.widgets import vuetify3 as vuetify -from .file_select import FileSelect - - -class Toolbar(vuetify.VAppBar): - def __init__( - self, - reset_function, - import_function, - export_function, - ui_main_drawer="ui_main_drawer", - ui_axis_drawer="ui_axis_drawer", - ui_action_name="ui_action_name", - ui_loading="ui_loading", - ui_unapplied_changes="ui_unapplied_changes", - da_active="da_active", - da_size="da_size", - render_auto="render_auto", - ): - super().__init__() - with self: - with vuetify.VBtn( - size="x-large", - classes="pa-0 ma-0", - style="min-width: 60px", - click=f"{ui_main_drawer} = !{ui_main_drawer}", - ): - vuetify.VIcon("mdi-database-cog-outline") - vuetify.VIcon( - "{{ %s? 'mdi-chevron-left' : 'mdi-chevron-right' }}" - % ui_main_drawer - ) - - vuetify.VAppBarTitle("GeoTrame") - vuetify.VProgressLinear( - v_show=(ui_loading,), - indeterminate=True, - absolute=True, - ) - with html.Div( - classes="d-flex flex-row-reverse fill-height", - style="column-gap: 10px; align-items: center", - ): - with vuetify.VBtn( - size="x-large", - classes="pa-0 ma-0", - style="min-width: 60px", - click=f"{ui_axis_drawer} = !{ui_axis_drawer}", - ): - vuetify.VIcon("mdi-axis-arrow-info") - vuetify.VIcon( - "{{ %s? 'mdi-chevron-right' : 'mdi-chevron-left' }}" - % ui_axis_drawer - ) - vuetify.VCheckbox( - label="Auto Render", v_model=(render_auto,), hide_details=True - ) - with vuetify.VBtn( - click=reset_function, - v_show=(f"{ui_unapplied_changes} && !{render_auto}",), - variant="tonal", - ): - html.Span("Apply & Render") - html.Span("({{ %s }})" % da_size, v_show=(da_size,)) - vuetify.VBtn( - click=f"{ui_action_name} = 'Export'", - variant="tonal", - text="Export", - ) - vuetify.VBtn( - click=f"{ui_action_name} = 'Import'", - variant="tonal", - text="Import", - ) - with vuetify.VDialog(v_model=(ui_action_name,), max_width=800): - FileSelect( - import_function, - export_function, - ui_action_name=ui_action_name, - ) diff --git a/pan3d/ui/vtk_view.py b/pan3d/ui/vtk_view.py new file mode 100644 index 00000000..a0a40d5a --- /dev/null +++ b/pan3d/ui/vtk_view.py @@ -0,0 +1,237 @@ +from trame.decorators import TrameApp, change +from trame.widgets import html, vtk as vtkw, vuetify3 as v3, vtklocal as wasm + +from pan3d.utils.constants import VIEW_UPS +from pan3d.ui.css import base, vtk_view + + +@TrameApp() +class Pan3DView(html.Div): + def __init__( + self, + render_window, + import_pending="import_pending", + axis_names="axis_names", + local_rendering=None, + widgets=None, + **kwargs, + ): + super().__init__(classes="pan3d-view", **kwargs) + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(vtk_view) + + self._import_pending = import_pending + self.render_window = render_window + self.renderer = render_window.GetRenderers().GetFirstRenderer() + self.camera = self.renderer.active_camera + + # Expose view_reset_clipping_range + self.ctrl.view_reset_clipping_range = self.renderer.ResetCameraClippingRange + + # Reserved state with default + self.state.setdefault("view_3d", True) + self.state.setdefault(import_pending, False) + + with self: + # 3D view + if local_rendering is not None: + if local_rendering == "wasm": + with wasm.LocalView(self.render_window, throttle_rate=10) as view: + self.ctrl.view_update_force = view.update + self.ctrl.view_update = view.update_throttle + self.ctrl.view_reset_camera = view.reset_camera + for w in widgets or []: + view.register_widget(w) + else: + with vtkw.VtkLocalView(self.render_window) as view: + self.ctrl.view_update = view.update + self.ctrl.view_reset_camera = view.reset_camera + view.set_widgets(widgets or []) + else: + with vtkw.VtkRemoteView( + self.render_window, interactive_ratio=1 + ) as view: + self.ctrl.view_update = view.update + self.ctrl.view_reset_camera = view.reset_camera + + # Scroll locking overlay + html.Div(v_show=("view_locked", False), classes="view-lock") + + # 3D toolbox + with v3.VCard(classes="view-toolbar pa-1", rounded="lg"): + with v3.VTooltip(text="Lock view interaction"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon=( + "view_locked ? 'mdi-lock-outline' : 'mdi-lock-off-outline'", + ), + click="view_locked = !view_locked", + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Reset camera"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-crop-free", + click=self.ctrl.view_reset_camera, + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Toggle between 3D/2D interaction"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon=("view_3d ? 'mdi-rotate-orbit' : 'mdi-cursor-move'",), + click="view_3d = !view_3d", + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Rotate left 90"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-rotate-left", + click=(self.rotate_camera, "[-1]"), + ) + with v3.VTooltip(text="Rotate right 90"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-rotate-right", + click=(self.rotate_camera, "[+1]"), + ) + v3.VDivider(classes="my-1") + with v3.VTooltip( + text=(f"`Look toward ${{ {axis_names}[0] || 'X' }}`",) + ): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-x-arrow", + click=(self.reset_camera_to_axis, "[[1,0,0]]"), + ) + with v3.VTooltip(text=(f"`Look toward ${{ {axis_names}[1] || 'Y'}}`",)): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-y-arrow", + click=(self.reset_camera_to_axis, "[[0,1,0]]"), + ) + + with v3.VTooltip(text=(f"`Look toward ${{ {axis_names}[2] || 'Z'}}`",)): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-z-arrow", + click=(self.reset_camera_to_axis, "[[0,0,1]]"), + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Look toward at an angle"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-arrow", + click=(self.reset_camera_to_axis, "[[1,1,1]]"), + ) + + def reset_camera_to_axis(self, axis): + camera = self.renderer.active_camera + camera.focal_point = (0, 0, 0) + camera.position = axis + camera.view_up = VIEW_UPS.get(tuple(axis)) + self.ctrl.view_reset_camera() + + def rotate_camera(self, direction): + camera = self.renderer.active_camera + a = [*camera.view_up] + b = [camera.focal_point[i] - camera.position[i] for i in range(3)] + view_up = [ + direction * (a[1] * b[2] - a[2] * b[1]), + direction * (a[2] * b[0] - a[0] * b[2]), + direction * (a[0] * b[1] - a[1] * b[0]), + ] + camera.view_up = view_up + + if self.state.disable_rendering: + return + + self.ctrl.view_update() + + @change("view_3d") + def _on_view_type_change(self, view_3d, **_): + # FIXME properly swap interactor style + if view_3d: + # self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.renderer.GetActiveCamera().SetParallelProjection(0) + else: + # self.interactor.GetInteractorStyle().SetCu() + self.renderer.GetActiveCamera().SetParallelProjection(1) + + if not self.state[self._import_pending]: + self.ctrl.view_reset_camera() + + +class Pan3DScalarBar(v3.VTooltip): + def __init__(self, img_src, color_min="color_min", color_max="color_max", **kwargs): + super().__init__(location="top") + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(vtk_view) + + self.state.setdefault("scalarbar_probe", []) + self.state.client_only("scalarbar_probe", "scalarbar_probe_available") + + with self: + # Content + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="scalarbar", + rounded="pill", + v_bind="props", + **kwargs, + ): + html.Div( + f"{{{{ {color_min}.toFixed(6) }}}}", classes="scalarbar-left" + ) + html.Img( + src=(img_src, None), + style="height: 100%; width: 100%;", + classes="rounded-lg border-thin", + mousemove="scalarbar_probe = [$event.x, $event.target.getBoundingClientRect()]", + mouseenter="scalarbar_probe_available = 1", + mouseleave="scalarbar_probe_available = 0", + __events=["mousemove", "mouseenter", "mouseleave"], + ) + html.Div( + v_show=("scalarbar_probe_available", False), + classes="scalar-cursor", + style=( + "`left: ${scalarbar_probe?.[0] - scalarbar_probe?.[1]?.left}px`", + ), + ) + html.Div( + f"{{{{ {color_max}.toFixed(6) }}}}", classes="scalarbar-right" + ) + html.Span( + f"{{{{ (({color_max} - {color_min}) * (scalarbar_probe?.[0] - scalarbar_probe?.[1]?.left) / scalarbar_probe?.[1]?.width + {color_min}).toFixed(6) }}}}" + ) diff --git a/pan3d/utils/constants.py b/pan3d/utils/constants.py index d83ad8e1..92fc1f7c 100644 --- a/pan3d/utils/constants.py +++ b/pan3d/utils/constants.py @@ -39,93 +39,14 @@ def has_gpu_rendering(): }, ] +# Used in preview -initial_state = { - "trame__title": "GeoTrame", - "dataset_ready": False, - "state_export": None, - "available_catalogs": [], - "catalog": None, - "catalog_current_search": {}, - "available_data_groups": [ - {"name": "Xarray", "value": "xarray"}, - ], - "data_group": "xarray", - "ui_group_loading": False, - "available_datasets": {"xarray": XARRAY_EXAMPLES}, - "dataset_info": None, - "da_active": None, - "da_vars": [], - "da_attrs": [], - "da_coordinates": [], - "da_x": None, - "da_y": None, - "da_z": None, - "da_t": None, - "da_t_index": 0, - "da_size": None, - "da_auto_slicing": True, - "cube_view_mode": False, - "cube_preview": None, - "cube_preview_face": "-Z", - "cube_preview_face_options": [], - "cube_preview_axes": None, - "ui_loading": False, - "ui_import_loading": False, - "ui_main_drawer": False, - "ui_axis_drawer": False, - "ui_bounds_menu": False, - "ui_render_options_menu": False, - "ui_unapplied_changes": False, - "ui_error_message": None, - "ui_more_info_link": None, - "ui_expanded_coordinates": [], - "ui_action_name": None, - "ui_action_message": None, - "ui_action_config_file": None, - "ui_search_catalogs": False, - "ui_catalog_term_search_loading": False, - "ui_catalog_search_message": None, - "render_auto": False, - "render_x_scale": 1, - "render_y_scale": 1, - "render_z_scale": 1, - "render_scalar_warp": False, - "render_cartographic": False, - "render_transparency": False, - "render_transparency_function": "linear", - "render_transparency_function_options": [ - "linear", - "linear_r", - "geom", - "geom_r", - "sigmoid", - "sigmoid_r", - ], - "render_colormap": "viridis", - "render_colormap_options": [ - "viridis", - "plasma", - "inferno", - "magma", - "terrain", - "ocean", - "cividis", - "seismic", - "rainbow", - "jet", - "turbo", - "gray", - "cool", - "hot", - "coolwarm", - "hsv", - ], -} - -coordinate_auto_selection = { - "x": ["x", "i", "lon", "len", "nx"], - "y": ["y", "j", "lat", "width", "ny"], - "z": ["z", "k", "depth", "height", "nz", "level"], - "t": ["t", "time", "year", "month", "date", "day", "hour", "minute", "second"], +XYZ = ["x", "y", "z"] +SLICE_VARS = ["slice_{}_range", "slice_{}_cut", "slice_{}_type", "slice_{}_step"] +VIEW_UPS = { + (1, 1, 1): (0, 0, 1), + (-1, -1, 1): (0, 0, 1), + (1, 0, 0): (0, 0, 1), + (0, 1, 0): (0, 0, 1), + (0, 0, 1): (0, 1, 0), } diff --git a/pan3d/utils/convert.py b/pan3d/utils/convert.py new file mode 100644 index 00000000..b1ddbae3 --- /dev/null +++ b/pan3d/utils/convert.py @@ -0,0 +1,59 @@ +import math +import vtk +import base64 + + +def to_float(v): + v = float(v) + if math.isnan(v) or v < 0.0001: + v = 0.0001 + return v + + +def max_str_length(labels): + size = 0 + for label in labels: + s = len(label) + if s > size: + size = s + + return size + + +def update_camera(camera, props): + for k, v in props.items(): + setattr(camera, k, v) + + +def to_image(lut, samples=255): + colorArray = vtk.vtkUnsignedCharArray() + colorArray.SetNumberOfComponents(3) + colorArray.SetNumberOfTuples(samples) + + dataRange = lut.GetRange() + delta = (dataRange[1] - dataRange[0]) / float(samples) + + # Add the color array to an image data + imgData = vtk.vtkImageData() + imgData.SetDimensions(samples, 1, 1) + imgData.GetPointData().SetScalars(colorArray) + + # Loop over all presets + rgb = [0, 0, 0] + for i in range(samples): + lut.GetColor(dataRange[0] + float(i) * delta, rgb) + r = int(round(rgb[0] * 255)) + g = int(round(rgb[1] * 255)) + b = int(round(rgb[2] * 255)) + colorArray.SetTuple3(i, r, g, b) + + writer = vtk.vtkPNGWriter() + writer.WriteToMemoryOn() + writer.SetInputData(imgData) + writer.SetCompressionLevel(6) + writer.Write() + + writer.GetResult() + + base64_img = base64.standard_b64encode(writer.GetResult()).decode("utf-8") + return f"data:image/png;base64,{base64_img}" diff --git a/pan3d/utils/presets.py b/pan3d/utils/presets.py index 3d6d0318..fd904e84 100644 --- a/pan3d/utils/presets.py +++ b/pan3d/utils/presets.py @@ -53,7 +53,9 @@ def convert_tfunc_to_lut(tfunc: vtk.vtkColorTransferFunction, tfrange): return lut -def apply_preset(actor: vtk.vtkActor, srange, preset: str) -> None: +def apply_preset( + actor: vtk.vtkActor, srange, preset: str, nan_color=[0, 0, 0, 0] +) -> None: if preset in list(hsv_colors.keys()): lut = vtk.vtkLookupTable() lut.SetNumberOfTableValues(256) @@ -66,12 +68,14 @@ def apply_preset(actor: vtk.vtkActor, srange, preset: str) -> None: lut.SetHueRange(hue[0], hue[1]) lut.SetSaturationRange(sat[0], sat[1]) lut.SetValueRange(rng[0], rng[1]) + lut.SetNanColor(nan_color) lut.Build() elif preset in list(rgb_colors.keys()): info = rgb_colors[preset] tfunc = info["TF"] tfrange = info["Range"] lut = convert_tfunc_to_lut(tfunc, tfrange) + lut.SetNanColor(nan_color) mapper = actor.GetMapper() mapper.SetLookupTable(lut) mapper.SetScalarRange(srange[0], srange[1]) diff --git a/pan3d/viewers/__init__.py b/pan3d/viewers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pan3d/viewers/catalog.py b/pan3d/viewers/catalog.py new file mode 100644 index 00000000..5c9173dd --- /dev/null +++ b/pan3d/viewers/catalog.py @@ -0,0 +1,456 @@ +import json +import asyncio +from pathlib import Path + +from trame.decorators import TrameApp, change +from trame.app import get_server + +from trame.ui.vuetify3 import SinglePageWithDrawerLayout +from trame.widgets import vuetify3 as v3, html +from trame_client.widgets.core import TrameDefault + +from pan3d import catalogs as pan3d_catalogs +from pan3d.ui.catalog_search import CatalogSearch +from pan3d.xarray.algorithm import vtkXArrayRectilinearSource + + +@TrameApp() +class CatalogBrowser: + def __init__(self, server=None): + self.server = get_server(server) + self.current_event_loop = asyncio.get_event_loop() + + # dev setup + if self.server.hot_reload: + self.ctrl.on_server_reload.add(self._build_ui) + + # Xarray helper + self.reader = vtkXArrayRectilinearSource() + + # List options + self.state.update( + { + "xr_meta": {}, + "xr_vars": [], + "available_datasets": [], + "available_data_groups": [], + } + ) + self.state.available_catalogs = pan3d_catalogs.list_availables_search() + + # self.state.available_datasets = [ + # { + # "id": "MITgcm_channel_flatbottom_02km_run01_phys-mon", + # "subtitle": "MITgcm channel simulations with flat bottom at 2km resolution physics field monthly mean climatology\n", + # "value": { + # "source": "pangeo", + # "id": "MITgcm_channel_flatbottom_02km_run01_phys-mon", + # }, + # }, + # ] + + # Build UI + self.ui = None + self._build_ui() + + # ------------------------------------------------------------------------- + # Trame API + # ------------------------------------------------------------------------- + + def start(self, **kwargs): + """Initialize the UI and start the server for GeoTrame.""" + self.ui.server.start(**kwargs) + + @property + async def ready(self): + """Coroutine to wait for the GeoTrame server to be ready.""" + await self.ui.ready + + @property + def state(self): + """Returns the current State of the Trame server.""" + return self.server.state + + @property + def ctrl(self): + """Returns the Controller for the Trame server.""" + return self.server.controller + + # ------------------------------------------------------------------------- + # GUI + # ------------------------------------------------------------------------- + + def _build_ui(self, *args, **kwargs): + self.state.trame__title = "XArray Catalog Browser" + with SinglePageWithDrawerLayout(self.server, full_height=True) as layout: + self.ui = layout + + with layout.toolbar as tb: + tb.density = "compact" + layout.title.set_text("XArray Catalog Browser") + v3.VSpacer() + v3.VBtn( + icon="mdi-search-web", + click="ui_search_catalogs = !ui_search_catalogs", + ) + v3.VProgressLinear( + indeterminate=("trame__busy",), + absolute=True, + location="bottom", + color="primary", + bg_color="rgba(0,0,0,0)", + opacity=0.2, + height=3, + ) + + CatalogSearch( + update_catalog_search_term_function=self._update_catalog_search_term, + catalog_search_function=self._catalog_search, + catalog_term_search_function=self._catalog_term_option_search, + ) + + with layout.drawer as drawer: + drawer.width = 350 + with v3.VToolbar( + density="compact", + flat=True, + color="white", + classes="pa-1 border-b-thin", + ): + v3.VTextField( + placeholder="filter...", + v_model=("filter_query", ""), + density="compact", + hide_details=True, + prepend_inner_icon="mdi-magnify", + variant="outlined", + rounded=True, + clearable=True, + ) + v3.VBtn( + icon="mdi-trash-can-outline", + flat=True, + density="compact", + classes="mx-1", + click="selected_dataset=[];available_datasets=[];", + ) + + v3.VList( + mandatory=True, + selectable=True, + density="compact", + items=( + "available_datasets.filter((v) => !filter_query || (filter_query.length === 0) || v.subtitle.toLowerCase().includes(filter_query.toLowerCase()) || v.id.toLowerCase().includes(filter_query.toLowerCase()))", + TrameDefault( + available_datasets=[], + selected_dataset=[], + ), + ), + item_props=True, + item_value="value", + lines="three", + raw_attrs=['v-model:selected="selected_dataset"'], + ) + + with layout.content: + with v3.VContainer(fluid=True, classes="full-height pa-0"): + with v3.VCard(flat=True, tile=True, v_if="selected_dataset.length"): + with v3.VCardTitle( + "{{ selected_dataset[0]?.id}}", + classes="d-flex", + ): + v3.VSpacer() + v3.VLabel("({{ selected_dataset[0]?.source }})") + v3.VBtn( + icon="mdi-cloud-download-outline", + density="compact", + click=self.save_data_origin, + flat=True, + classes="ml-2", + ) + v3.VBtn( + icon="mdi-file-download-outline", + density="compact", + click=self.save_dataset, + flat=True, + classes="ml-2", + ) + v3.VDivider() + v3.VSelect( + label="Coordinates", + v_model=("xr_coord", None), + items=("xr_coords", []), + density="compact", + hide_details=True, + flat=True, + variant="outlined", + classes="ma-2", + ) + + with v3.VTable(density="compact"): + with html.Tbody(): + with html.Tr( + v_for="attr, j in xr_meta[xr_coord] || []", + key="j", + ): + html.Td("{{ attr.key }}") + html.Td("{{ attr.value }}") + + v3.VSelect( + label="Data arrays", + v_model=("xr_array", None), + items=("xr_arrays", []), + density="compact", + hide_details=True, + flat=True, + variant="outlined", + classes="ma-2", + ) + + with v3.VTable(density="compact"): + with html.Tbody(): + with html.Tr( + v_for="attr, j in xr_meta[xr_array] || []", + key="j", + ): + html.Td("{{ attr.key }}") + html.Td("{{ attr.value }}") + v3.VSelect( + v_if="xr_vars.length", + label="Variables", + v_model=("xr_var", None), + items=("xr_vars", []), + density="compact", + hide_details=True, + flat=True, + variant="outlined", + classes="ma-2", + ) + + with v3.VTable(density="compact", v_if="xr_vars.length"): + with html.Tbody(): + with html.Tr( + v_for="attr, j in xr_meta[xr_var] || []", + key="j", + ): + html.Td("{{ attr.key }}") + html.Td("{{ attr.value }}") + v3.VCardText( + "Search dataset from the catalogs and select one from the list on the left to explore its content.", + v_else=True, + ) + + # ------------------------------------------------------------------------- + # Triggers + # ------------------------------------------------------------------------- + + def _update_catalog_search_term(self, term_key, term_value): + self.state.catalog_current_search[term_key] = term_value + self.state.dirty("catalog_current_search") + + def _catalog_search(self): + def load_results(): + catalog_id = self.state.catalog.get("id") + results, group_name, message = pan3d_catalogs.search( + catalog_id, **self.state.catalog_current_search + ) + + if len(results) > 0: + self.state.available_data_groups.append( + {"name": group_name, "value": group_name} + ) + self.state.available_datasets.extend(results) + self.state.ui_catalog_search_message = message + self.state.dirty("available_data_groups", "available_datasets") + else: + self.state.ui_catalog_search_message = ( + "No results found for current search criteria." + ) + + self.run_as_async( + load_results, + loading_state="ui_catalog_term_search_loading", + error_state="ui_catalog_search_message", + unapplied_changes_state=None, + ) + + def _catalog_term_option_search(self): + def load_terms(): + catalog_id = self.state.catalog.get("id") + search_options = pan3d_catalogs.get_search_options(catalog_id) + self.state.available_catalogs = [ + ( + { + **catalog, + "search_terms": [ + {"key": k, "options": v} for k, v in search_options.items() + ], + } + if catalog.get("id") == catalog_id + else catalog + ) + for catalog in self.state.available_catalogs + ] + for catalog in self.state.available_catalogs: + if catalog.get("id") == catalog_id: + self.state.catalog = catalog + + self.run_as_async( + load_terms, + loading_state="ui_catalog_term_search_loading", + error_state="ui_catalog_search_message", + unapplied_changes_state=None, + ) + + def run_as_async( + self, + function, + loading_state="ui_loading", + error_state="ui_error_message", + unapplied_changes_state="ui_unapplied_changes", + ): + async def run(): + with self.state: + if loading_state is not None: + self.state[loading_state] = True + if error_state is not None: + self.state[error_state] = None + if unapplied_changes_state is not None: + self.state[unapplied_changes_state] = False + + await asyncio.sleep(0.001) + + with self.state: + try: + function() + except Exception as e: + if error_state is not None: + self.state[error_state] = str(e) + else: + raise e + if loading_state is not None: + self.state[loading_state] = False + + await asyncio.sleep(0.001) + + if self.current_event_loop.is_running(): + asyncio.run_coroutine_threadsafe(run(), self.current_event_loop) + else: + # Pytest environment needs synchronous execution + function() + + def save_data_origin(self): + selected_dataset = self.state.selected_dataset + if not selected_dataset: + return + + file = Path(f"{selected_dataset[0].get('id')}.data-origin.json") + file.write_text(json.dumps(dict(data_origin=selected_dataset[0]))) + + def save_dataset(self): + selected_dataset = self.state.selected_dataset + if not selected_dataset: + return + + name = f"{selected_dataset[0].get('id')}.nc" + + self.reader.input.to_netcdf(name) + + # ----------------------------------------------------- + # State change callbacks + # ----------------------------------------------------- + + @change("ui_search_catalogs") + def _on_change_ui_search_catalogs(self, ui_search_catalogs, **kwargs): + if ui_search_catalogs: + self.state.catalog = self.state.available_catalogs[0] + else: + self.state.catalog = None + + @change("catalog") + def _on_change_catalog(self, **_): + self.state.catalog_current_search = {} + self.state.ui_catalog_search_message = None + + @change("selected_dataset") + def on_selection_change(self, selected_dataset, **_): + if not selected_dataset: + self.state.xarray_info = [] + return + + # Load metadata + self.reader.load(dict(data_origin=selected_dataset[0])) + xr = self.reader.input + available_arrays = self.reader.available_arrays + + # Extract metadata + xr_meta = {} + xr_coords = [] + xr_arrays = [] + xr_vars = [] + + coords = set(xr.coords.keys()) + data = set(available_arrays or []) + for name in xr.variables: + attrs = xr_meta.setdefault(name, []) + attrs.extend( + [{"key": str(k), "value": str(v)} for k, v in xr[name].attrs.items()] + ) + attrs.insert( + 0, + {"key": "type", "value": str(xr[name].dtype)}, + ) + attrs.insert( + 0, + { + "key": "shape", + "value": list(xr[name].shape), + }, + ) + if name in coords: + xr_coords.append(name) + length = xr[name].size + shape = xr[name].shape + if length > 1 and len(shape) == 1: + attrs.insert( + 0, + { + "key": "range", + "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", + }, + ) + + elif name in data: + attrs.insert( + 0, + { + "key": "dimensions", + "value": f'({",".join(xr[name].dims)})', + }, + ) + xr_arrays.append(name) + else: + xr_vars.append(name) + + # Update UI + self.state.xr_meta = xr_meta + self.state.xr_coords = xr_coords + self.state.xr_arrays = xr_arrays + self.state.xr_vars = xr_vars + self.state.xr_coord = xr_coords[0] if len(xr_coords) else "" + self.state.xr_array = xr_arrays[0] if len(xr_arrays) else "" + self.state.xr_var = xr_vars[0] if len(xr_vars) else "" + + +# ----------------------------------------------------------------------------- +# Main executable +# ----------------------------------------------------------------------------- + + +def main(): + app = CatalogBrowser() + app.start() + + +if __name__ == "__main__": + main() diff --git a/pan3d/viewers/preview.py b/pan3d/viewers/preview.py new file mode 100644 index 00000000..1111a762 --- /dev/null +++ b/pan3d/viewers/preview.py @@ -0,0 +1,478 @@ +import vtk +from vtkmodules.vtkInteractionWidgets import vtkOrientationMarkerWidget +from vtkmodules.vtkRenderingAnnotation import vtkAxesActor + +import json +import traceback +from pathlib import Path + +from trame.decorators import TrameApp, change +from trame.app import get_server, asynchronous + +from trame.ui.vuetify3 import VAppLayout +from trame.widgets import vuetify3 as v3 + +from pan3d.xarray.algorithm import vtkXArrayRectilinearSource + +from pan3d.utils.convert import update_camera, to_image, to_float +from pan3d.utils.presets import apply_preset + +from pan3d.ui.vtk_view import Pan3DView, Pan3DScalarBar +from pan3d.ui.preview import SummaryToolbar, ControlPanel + + +@TrameApp() +class XArrayViewer: + """Create a Trame GUI for a Pan3D XArray Viewer""" + + def __init__(self, server=None, local_rendering=None): + """Create an instance of the XArrayViewer class. + + Parameters: + server: Trame server name or instance. + """ + self.server = get_server(server, client_type="vue3") + if self.server.hot_reload: + self.ctrl.on_server_reload.add(self._build_ui) + + # cli + self.server.cli.add_argument( + "--import-state", + help="Pass a string with this argument to specify a startup configuration. This value must be a local path to a JSON file which adheres to the schema specified in the [Configuration Files documentation](../api/configuration.md). A dataset specified in this configuration will override any value passed to `--xarray-*`", + ) + self.server.cli.add_argument( + "--xarray-file", + help="Provide path to xarray file", + ) + self.server.cli.add_argument( + "--xarray-url", + help="Provide URL to xarray dataset", + ) + + # Local rendering setup + self.server.cli.add_argument( + "--wasm", + help="Use WASM for local rendering", + action="store_true", + ) + self.server.cli.add_argument( + "--vtkjs", + help="Use vtk.js for local rendering", + action="store_true", + ) + args, _ = self.server.cli.parse_known_args() + self.local_rendering = local_rendering + if args.wasm: + self.local_rendering = "wasm" + if args.vtkjs: + self.local_rendering = "vtkjs" + + # Process CLI + self.ctrl.on_server_ready.add(self._process_cli) + + self.state.nan_colors = [ + [0, 0, 0, 1], + [0.99, 0.99, 0.99, 1], + [0.6, 0.6, 0.6, 1], + [1, 0, 0, 1], + [0, 1, 0, 1], + [0, 0, 1, 1], + [0.9, 0.9, 0.9, 0], + ] + self.state.nan_color = 2 + + self.ui = None + self.disable_rendering # initialize state + self._setup_vtk() + self._build_ui() + + # ------------------------------------------------------------------------- + # VTK Setup + # ------------------------------------------------------------------------- + + def _setup_vtk(self): + self.renderer = vtk.vtkRenderer(background=(0.8, 0.8, 0.8)) + self.interactor = vtk.vtkRenderWindowInteractor() + self.render_window = vtk.vtkRenderWindow(off_screen_rendering=1) + + self.render_window.AddRenderer(self.renderer) + self.interactor.SetRenderWindow(self.render_window) + self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + + self.source = vtkXArrayRectilinearSource() + + # Need explicit geometry extraction when used with WASM + self.geometry = vtk.vtkDataSetSurfaceFilter( + input_connection=self.source.output_port + ) + self.mapper = vtk.vtkPolyDataMapper(input_connection=self.geometry.output_port) + self.actor = vtk.vtkActor(mapper=self.mapper, visibility=0) + + self.interactor.Initialize() + + axes_actor = vtkAxesActor() + self.widget = vtkOrientationMarkerWidget() + self.widget.SetOrientationMarker(axes_actor) + self.widget.SetInteractor(self.interactor) + self.widget.SetViewport(0.85, 0, 1, 0.15) + self.widget.EnabledOn() + self.widget.InteractiveOff() + + # ------------------------------------------------------------------------- + # Trame API + # ------------------------------------------------------------------------- + + def start(self, **kwargs): + """Initialize the UI and start the server for GeoTrame.""" + self.ui.server.start(**kwargs) + + @property + async def ready(self): + """Coroutine to wait for the GeoTrame server to be ready.""" + await self.ui.ready + + @property + def state(self): + """Returns the current State of the Trame server.""" + return self.server.state + + @property + def ctrl(self): + """Returns the Controller for the Trame server.""" + return self.server.controller + + @property + def disable_rendering(self): + return self.state.setdefault("disable_rendering", False) + + @disable_rendering.setter + def disable_rendering(self, v): + self.state.disable_rendering = v + + # ------------------------------------------------------------------------- + # UI + # ------------------------------------------------------------------------- + + def _build_ui(self, **kwargs): + self.state.trame__title = "XArray Viewer" + + with VAppLayout(self.server, fill_height=True) as layout: + self.ui = layout + + # 3D view + Pan3DView( + self.render_window, + local_rendering=self.local_rendering, + widgets=[self.widget], + ) + + # Scalar bar + Pan3DScalarBar( + v_show="!control_expended", + v_if="color_by", + img_src="preset_img", + ) + + # Save dialog + with v3.VDialog(v_model=("show_save_dialog", False)): + with v3.VCard(classes="mx-auto w-50"): + v3.VCardTitle("Save dataset to disk") + v3.VDivider() + with v3.VCardText(): + v3.VTextField( + label="File path to save", + v_model=("save_dataset_path", ""), + hide_details=True, + ) + with v3.VCardActions(): + v3.VSpacer() + v3.VBtn( + "Save", + classes="text-none", + variant="flat", + color="primary", + click=self.save_dataset, + ) + v3.VBtn( + "Cancel", + classes="text-none", + variant="flat", + click="show_save_dialog=false", + ) + + # Error messages + v3.VAlert( + v_if=("data_origin_error", False), + border="start", + max_width=700, + rounded="lg", + text=("data_origin_error", ""), + title="Failed to load data", + type="error", + variant="tonal", + style="position:absolute;bottom:1rem;right:1rem;", + ) + + # Summary toolbar + SummaryToolbar( + v_show="!control_expended", + v_if="slice_t_max > 0", + ) + + # Control panel + ControlPanel( + source=self.source, + toggle="control_expended", + load_dataset=self._load_dataset, + update_rendering=self._update_rendering, + import_file_upload=self._import_file_upload, + export_file_download=self.export_state, + xr_update_info="xr_update_info", + source_update_rendering="source_update_rendering_panel", + ) + + # ----------------------------------------------------- + # State change callbacks + # ----------------------------------------------------- + + @change("color_by") + def _on_color_by(self, color_by, **__): + if self.source.input is None: + return + + ds = self.source() + if color_by in ds.point_data.keys(): + array = ds.point_data[color_by] + min_value, max_value = array.GetRange() + + self.state.color_min = min_value + self.state.color_max = max_value + + self.mapper.SelectColorArray(color_by) + self.mapper.SetScalarModeToUsePointFieldData() + self.mapper.InterpolateScalarsBeforeMappingOn() + self.mapper.SetScalarVisibility(1) + else: + self.mapper.SetScalarVisibility(0) + self.state.color_min = 0 + self.state.color_max = 1 + + @change("color_preset", "color_min", "color_max", "nan_color") + def _on_color_preset( + self, nan_color, nan_colors, color_preset, color_min, color_max, **_ + ): + color_min = float(color_min) + color_max = float(color_max) + color = nan_colors[nan_color] + self.mapper.SetScalarRange(color_min, color_max) + apply_preset(self.actor, [color_min, color_max], color_preset, color) + self.state.preset_img = to_image(self.actor.mapper.lookup_table, 255) + + if self.disable_rendering: + return + + self.ctrl.view_update() + + @change("scale_x", "scale_y", "scale_z") + def _on_scale_change(self, scale_x, scale_y, scale_z, **_): + self.actor.SetScale( + to_float(scale_x), + to_float(scale_y), + to_float(scale_z), + ) + + if self.state.import_pending: + return + + if self.disable_rendering: + return + + if self.actor.visibility: + if self.local_rendering: + self.ctrl.view_update() + self.ctrl.view_reset_camera() + + @change("data_origin_order") + def _on_order_change(self, **_): + if self.state.import_pending: + return + + self.state.load_button_text = "Load" + self.state.can_load = True + + # ----------------------------------------------------- + # Triggers + # ----------------------------------------------------- + + def _import_file_upload(self, files): + self.import_state(json.loads(files[0].get("content"))) + + def _process_cli(self, **_): + args, _ = self.server.cli.parse_known_args() + + # import state + if args.import_state: + self._import_file_from_path(args.import_state) + + # load xarray (file) + elif args.xarray_file: + self.state.import_pending = True + with self.state: + self._load_dataset("file", args.xarray_file) + self.state.data_origin_id = str(Path(args.xarray_file).resolve()) + self.state.import_pending = False + + # load xarray (url) + elif args.xarray_url: + self.state.import_pending = True + with self.state: + self._load_dataset("url", args.xarray_url) + self.state.data_origin_id = args.xarray_url + self.state.import_pending = False + + def _import_file_from_path(self, file_path): + if file_path is None: + return + + file_path = Path(file_path) + if file_path.exists(): + self.import_state(json.loads(file_path.read_text("utf-8"))) + + def _load_dataset(self, source, id, order="C", config=None): + self.state.data_origin_source = source + self.state.data_origin_id = id + self.state.load_button_text = "Loaded" + self.state.can_load = False + self.state.show_data_information = True + + if config is None: + config = { + "arrays": [], + "slices": {}, + } + + try: + self.source.load( + { + "data_origin": { + "source": source, + "id": id, + "order": order, + }, + "dataset_config": config, + } + ) + if self.actor.visibility: + self.renderer.RemoveActor(self.actor) + self.actor.visibility = 0 + + # Extract UI + self.ctrl.xr_update_info(self.source.input, self.source.available_arrays) + self.ctrl.source_update_rendering_panel(self.source) + + # no error + self.state.data_origin_error = False + except Exception as e: + self.state.data_origin_error = ( + f"Error occurred while trying to load data. {e}" + ) + self.state.data_origin_id_error = True + self.state.load_button_text = "Load" + self.state.can_load = True + self.state.show_data_information = False + + print(traceback.format_exc()) + + def _update_rendering(self, reset_camera=False): + self.state.dirty_data = False + + if self.disable_rendering: + return + + if self.actor.visibility == 0: + self.actor.visibility = 1 + self.renderer.AddActor(self.actor) + self.renderer.ResetCamera() + if self.ctrl.view_update_force.exists(): + self.ctrl.view_update_force(push_camera=True) + + if reset_camera: + self.ctrl.view_reset_camera() + else: + self.ctrl.view_update() + + # ----------------------------------------------------- + # Public API + # ----------------------------------------------------- + + def export_state(self): + camera = self.renderer.active_camera + state_to_export = { + **self.source.state, + "preview": { + "view_3d": self.state.view_3d, + "color_by": self.state.color_by, + "color_preset": self.state.color_preset, + "color_min": self.state.color_min, + "color_max": self.state.color_max, + "scale_x": self.state.scale_x, + "scale_y": self.state.scale_y, + "scale_z": self.state.scale_z, + }, + "camera": { + "position": camera.position, + "view_up": camera.view_up, + "focal_point": camera.focal_point, + "parallel_projection": camera.parallel_projection, + "parallel_scale": camera.parallel_scale, + }, + } + return json.dumps(state_to_export, indent=2) + + def import_state(self, data_state): + self.state.import_pending = True + try: + data_origin = data_state.get("data_origin") + source = data_origin.get("source") + id = data_origin.get("id") + order = data_origin.get("order", "C") + config = data_state.get("dataset_config") + preview_state = data_state.get("preview", {}) + camera_state = data_state.get("camera", {}) + + # load data and initial rendering setup + with self.state: + self._load_dataset(source, id, order, config) + self.state.update(preview_state) + + # override computed color range using state values + with self.state: + self.state.update(preview_state) + + # update camera and render + update_camera(self.renderer.active_camera, camera_state) + self._update_rendering() + finally: + self.state.import_pending = False + + async def _save_dataset(self): + output_path = Path(self.state.save_dataset_path).resolve() + self.source.input.to_netcdf(output_path) + + def save_dataset(self): + self.state.show_save_dialog = False + asynchronous.create_task(self._save_dataset()) + + +# ----------------------------------------------------------------------------- +# Main executable +# ----------------------------------------------------------------------------- + + +def main(): + app = XArrayViewer() + app.start() + + +if __name__ == "__main__": + main() diff --git a/pan3d/xarray/__init__.py b/pan3d/xarray/__init__.py new file mode 100644 index 00000000..f95dfe41 --- /dev/null +++ b/pan3d/xarray/__init__.py @@ -0,0 +1 @@ +import pan3d.xarray.accessor # noqa diff --git a/pan3d/xarray/accessor.py b/pan3d/xarray/accessor.py new file mode 100644 index 00000000..47a546cd --- /dev/null +++ b/pan3d/xarray/accessor.py @@ -0,0 +1,92 @@ +from typing import Dict, List, Optional + +import numpy as np +import xarray as xr + +from vtkmodules.vtkCommonDataModel import vtkDataSet +from pan3d.xarray import datasets, algorithm + + +class _LocIndexer: + def __init__(self, parent: "VTKAccessor"): + self.parent = parent + + def __getitem__(self, key) -> xr.DataArray: + return self.parent._xarray.loc[key] + + def __setitem__(self, key, value) -> None: + self.parent._xarray.__setitem__(self, key, value) + + +@xr.register_dataarray_accessor("vtk") +class VTKAccessor: + def __init__(self, xarray_obj: xr.DataArray): + self._xarray = xarray_obj + + def __getitem__(self, key): + return self._xarray.__getitem__(key) + + @property + def data(self): + return self._xarray.values + + @property + def loc(self) -> _LocIndexer: + """Attribute for location based indexing like pandas.""" + return _LocIndexer(self) + + def _get_array(self, key, scale=1): + try: + values = self._xarray[key].values + if "float" not in str(values.dtype) and "int" not in str(values.dtype): + # non-numeric coordinate, assign array of scaled indices + values = np.array(range(len(values))) * scale + + return values + except KeyError: + raise KeyError( + f"Key {key} not present in DataArray. Choices are: {list(self._xarray.coords.keys())}" + ) + + def dataset( + self, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + order: Optional[str] = None, + component: Optional[str] = None, + mesh_type: Optional[str] = None, + scales: Optional[Dict] = None, + ) -> vtkDataSet: + if mesh_type is None: # Try to guess mesh type + max_ndim = max( + *[self._get_array(n).ndim for n in (x, y, z) if n is not None] + ) + mesh_type = "structured" if max_ndim > 1 else "rectilinear" + + try: + builder = getattr(datasets, mesh_type) + except KeyError: + raise KeyError + return builder( + self, x=x, y=y, z=z, order=order, component=component, scales=scales + ) + + def algorithm( + self, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + t: Optional[str] = None, + arrays: Optional[List[str]] = None, + order: str = "C", + ): + return algorithm.vtkXArrayRectilinearSource( + input=self._xarray, + x=x, + y=y, + z=z, + t=t, + arrays=arrays, + order=order, + ) diff --git a/pan3d/xarray/algorithm.py b/pan3d/xarray/algorithm.py new file mode 100644 index 00000000..306f3771 --- /dev/null +++ b/pan3d/xarray/algorithm.py @@ -0,0 +1,524 @@ +import traceback +from typing import List, Optional + +import json +import numpy as np +import pandas as pd +import xarray as xr +from vtkmodules.vtkCommonDataModel import vtkRectilinearGrid +from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase +from vtkmodules.vtkFiltersCore import vtkArrayCalculator + +# ----------------------------------------------------------------------------- +# Helper functions +# ----------------------------------------------------------------------------- + + +def get_time_labels(times): + return [pd.to_datetime(time).strftime("%Y-%m-%d %H:%M:%S") for time in times] + + +def slice_array(array_name, dataset, slice_info): + if array_name is None: + return np.zeros(1, dtype=np.float32) + array = dataset[array_name].values + if slice_info is None: + return array + if isinstance(slice_info, int): + return array[slice_info] + return array[slice(*slice_info)] + + +def to_isel(slices_info, *array_names): + slices = {} + for name in array_names: + if name is None: + continue + + info = slices_info.get(name) + if info is None: + continue + if isinstance(info, int): + slices[name] = info + else: + slices[name] = slice(*info) + + return slices if slices else None + + +def is_time_type(dtype): + if np.issubdtype(dtype, np.datetime64): + return True + + if np.issubdtype(dtype, np.dtype("O")): + return True + + return False + + +# ----------------------------------------------------------------------------- +# VTK Algorithms +# ----------------------------------------------------------------------------- + + +class vtkXArrayRectilinearSource(VTKPythonAlgorithmBase): + def __init__( + self, + input: Optional[xr.Dataset] = None, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + t: Optional[str] = None, + arrays: Optional[List[str]] = None, + order: str = "C", + ): + VTKPythonAlgorithmBase.__init__( + self, + nInputPorts=0, + nOutputPorts=1, + outputType="vtkRectilinearGrid", + ) + # Data source + self._input = input + self._xarray_mesh = None + self._pipeline = None + self._computed = {} + self._data_origin = None + + # Array name selectors + self._x = x + self._y = y + self._z = z + self._t = t + + # Data sub-selection + self._array_names = set(arrays or []) + self._t_index = 0 + self._slices = None + + # Data order + self._order = order + + # Auto apply coords if none provided + if all(v is None for v in (x, y, z, t)): + self.apply_coords() + + if len(self._array_names) == 0: + self.arrays = self.available_arrays + + def __str__(self): + return f""" +x: {self.x} +y: {self.y} +z: {self.z} +t: {self.t} ({self.t_index + 1}/{self.t_size}) +loaded: {self.arrays} +all: {self.available_arrays} +slices: {json.dumps(self.slices, indent=2)} +computed: {json.dumps(self.computed, indent=2)} +order: {self._order} +""" + + # ------------------------------------------------------------------------- + # Data input + # ------------------------------------------------------------------------- + + @property + def input(self): + return self._input + + @input.setter + def input(self, xarray_dataset: xr.Dataset): + self._input = xarray_dataset + self._xarray_mesh = None + self.Modified() + + # ------------------------------------------------------------------------- + # Array selectors + # ------------------------------------------------------------------------- + + @property + def x(self): + return self._x + + @x.setter + def x(self, x_array_name: str): + if x_array_name is None: + if self._x is not None: + self._x = None + self._xarray_mesh = None + self.Modified() + return + + coords = self.available_coords + if x_array_name not in coords: + raise ValueError( + f"x={x_array_name} is not a coordinate array [{', '.join(coords)}]" + ) + if self._x != x_array_name: + self._x = x_array_name + self._xarray_mesh = None + self.Modified() + + @property + def x_size(self): + if self._x is None: + return 0 + return int(self._input[self._x].size) + + @property + def y(self): + return self._y + + @y.setter + def y(self, y_array_name: str): + if y_array_name is None: + if self._y is not None: + self._y = None + self._xarray_mesh = None + self.Modified() + return + + coords = self.available_coords + if y_array_name not in coords: + raise ValueError( + f"y={y_array_name} is not a coordinate array [{', '.join(coords)}]" + ) + if self._y != y_array_name: + self._y = y_array_name + self._xarray_mesh = None + self.Modified() + + @property + def y_size(self): + if self._y is None: + return 0 + return int(self._input[self._y].size) + + @property + def z(self): + return self._z + + @z.setter + def z(self, z_array_name: str): + if z_array_name is None: + if self._z is not None: + self._z = None + self._xarray_mesh = None + self.Modified() + return + + coords = self.available_coords + if z_array_name not in coords: + raise ValueError( + f"z={z_array_name} is not a coordinate array [{', '.join(coords)}]" + ) + if self._z != z_array_name: + self._z = z_array_name + self._xarray_mesh = None + self.Modified() + + @property + def z_size(self): + if self._z is None: + return 0 + return int(self._input[self._z].size) + + @property + def t(self): + return self._t + + @t.setter + def t(self, t_array_name: str): + if t_array_name is None: + if self._t is not None: + self._t = None + self._xarray_mesh = None + self.Modified() + return + + coords = self.available_coords + if t_array_name not in coords: + raise ValueError( + f"t={t_array_name} is not a coordinate array [{', '.join(coords)}]" + ) + if self._t != t_array_name: + self._t = t_array_name + self._xarray_mesh = None + self.Modified() + + @property + def slice_extents(self): + return { + coord_name: [0, self.input[coord_name].size - 1] + for coord_name in [self.x, self.y, self.z] + if coord_name is not None + } + + def apply_coords(self): + """Use array dims to map coordinates""" + if self.input is None: + return + + array_name = self.available_arrays[0] + coords = self._input[array_name].dims + + # print("=" * 60) + # for n in self.available_arrays: + # print(f"{n}: {self._input[n].dims}") + # print("=" * 60) + + # reset coords arrays + self.x = None + self.y = None + self.z = None + self.t = None + + # assign mapping + axes = ["t", "z", "y", "x"] + if len(coords) == 4: + for key, value in zip(axes, coords): + setattr(self, key, value) + elif len(coords) == 2: + axes.remove("t") + axes.remove("z") + for key, value in zip(axes, coords): + setattr(self, key, value) + elif len(coords) == 3: + # Is it 2D dataset with time or 3D dataset ? + outer_dtype = self._input[array_name][coords[0]].dtype + if is_time_type(outer_dtype): + axes.remove("z") + for key, value in zip(axes, coords): + setattr(self, key, value) + else: + axes.remove("t") + for key, value in zip(axes, coords): + setattr(self, key, value) + + @property + def available_coords(self): + if self._input is None: + return [] + + return list(self._input.coords.keys()) + + # ------------------------------------------------------------------------- + # Data sub-selection + # ------------------------------------------------------------------------- + + @property + def t_index(self): + return self._t_index + + @t_index.setter + def t_index(self, t_index: int): + if t_index != self._t_index: + self._t_index = t_index + self._xarray_mesh = None + self.Modified() + + @property + def t_size(self): + if self._t is None: + return 0 + return int(self._input[self._t].size) + + @property + def t_labels(self): + if self._t is None: + return [] + + t_array = self._input[self._t] + t_type = t_array.dtype + if np.issubdtype(t_type, np.datetime64): + return get_time_labels(t_array.values) + return [str(t) for t in t_array.values] + + @property + def arrays(self): + return list(self._array_names) + + @arrays.setter + def arrays(self, array_names: List[str]): + new_names = set(array_names or []) + if new_names != self._array_names: + self._array_names = new_names + self._xarray_mesh = None + self.Modified() + + @property + def available_arrays(self): + """List available data fields""" + if self._input is None: + return [] + + filtered_arrays = [] + max_dim = 0 + coords = set([k for k, v in self._input.coords.items() if len(v.shape) == 1]) + for name in set(self._input.data_vars.keys()) - set(self._input.coords.keys()): + if name.endswith("_bnds") or name.endswith("_bounds"): + continue + + dims = set(self._input[name].dims) + max_dim = max(max_dim, len(dims)) + if dims.issubset(coords): + filtered_arrays.append(name) + + return [n for n in filtered_arrays if len(self._input[n].shape) == max_dim] + + @property + def slices(self): + result = dict(self._slices or {}) + if self.t is not None: + result[self.t] = self.t_index + return result + + @slices.setter + def slices(self, v): + if v != self._slices: + self._slices = v + self._xarray_mesh = None + self.Modified() + + # ------------------------------------------------------------------------- + # properties + # ------------------------------------------------------------------------- + + @property + def order(self): + return self._order + + @order.setter + def order(self, order: str): + self._order = order + self._xarray_mesh = None + self.Modified() + + # ------------------------------------------------------------------------- + # add-on logic + # ------------------------------------------------------------------------- + + @property + def computed(self): + return self._computed + + @computed.setter + def computed(self, v): + if self._computed != v: + self._computed = v or {} + self._pipeline = None + scalar_arrays = self._computed.get("_use_scalars", []) + vector_arrays = self._computed.get("_use_vectors", []) + + for output_name, func in self._computed.items(): + if output_name[0] == "_": + continue + filter = vtkArrayCalculator( + result_array_name=output_name, + function=func, + ) + + # register array dependencies + for scalar_array in scalar_arrays: + filter.AddScalarArrayName(scalar_array) + for vector_array in vector_arrays: + filter.AddVectorArrayName(vector_array) + + if self._pipeline is None: + self._pipeline = filter + else: + self._pipeline = self._pipeline >> filter + + self.Modified() + + def load(self, data_info): + if "data_origin" not in data_info: + raise ValueError("Only state with data_origin can be loaded") + + from pan3d import catalogs + + self._data_origin = data_info["data_origin"] + self.input = catalogs.load_dataset( + self._data_origin["source"], self._data_origin["id"] + ) + + self.order = self._data_origin.get("order", "C") + + dataset_config = data_info.get("dataset_config") + if dataset_config is None: + self.apply_coords() + self.arrays = self.available_arrays + else: + self.x = dataset_config.get("x") + self.y = dataset_config.get("y") + self.z = dataset_config.get("z") + self.t = dataset_config.get("t") + self.slices = dataset_config.get("slices") + self.t_index = dataset_config.get("t_index", 0) + self.apply_coords() + self.arrays = dataset_config.get("arrays", self.available_arrays) + + @property + def state(self): + if self._data_origin is None: + raise RuntimeError( + "No state available without data origin. Need to use the load method to set the data origin." + ) + + return { + "data_origin": self._data_origin, + "dataset_config": { + k: getattr(self, k) + for k in ["x", "y", "z", "t", "slices", "t_index", "arrays"] + }, + } + + # ------------------------------------------------------------------------- + # Algorithm + # ------------------------------------------------------------------------- + + def RequestData(self, request, inInfo, outInfo): + # Use open data_array handle to fetch data at + # desired Level of Detail + try: + pdo = self.GetOutputData(outInfo, 0) + + # Generate mesh + if self._xarray_mesh is None: + # grid + mesh = vtkRectilinearGrid() + mesh.x_coordinates = slice_array( + self._x, self._input, self.slices.get(self._x) + ) + mesh.y_coordinates = slice_array( + self._y, self._input, self.slices.get(self._y) + ) + mesh.z_coordinates = slice_array( + self._z, self._input, self.slices.get(self._z) + ) + mesh.dimensions = [ + mesh.x_coordinates.size, + mesh.y_coordinates.size, + mesh.z_coordinates.size, + ] + # fields + indexing = to_isel(self.slices, self.x, self.y, self.z, self.t) + for field_name in self._array_names: + da = self._input[field_name] + if indexing is not None: + da = da.isel(indexing) + mesh.point_data[field_name] = da.values.ravel(order=self._order) + + self._xarray_mesh = mesh + + # Compute derived quantity + if self._pipeline is not None: + pdo.ShallowCopy(self._pipeline(self._xarray_mesh)) + else: + pdo.ShallowCopy(self._xarray_mesh) + + except Exception as e: + traceback.print_exc() + raise e + return 1 diff --git a/pan3d/xarray/datasets.py b/pan3d/xarray/datasets.py new file mode 100644 index 00000000..b9dc8ee3 --- /dev/null +++ b/pan3d/xarray/datasets.py @@ -0,0 +1,194 @@ +from typing import Dict, Optional + +import numpy as np +import warnings +from pan3d.xarray.errors import DataCopyWarning +from vtkmodules.vtkCommonDataModel import vtkRectilinearGrid, vtkStructuredGrid + + +def imagedata_to_rectilinear(image_data): + output = vtkRectilinearGrid() + output.SetDimensions(image_data.dimensions) + origin = image_data.origin + spacing = image_data.spacing + extent = image_data.extent + coords = [] + for axis in range(3): + coords.append( + np.array( + [ + origin[axis] + (spacing[axis] * i) + for i in range(extent[axis * 2], extent[axis * 2 + 1] + 1) + ], + dtype=np.double, + ) + ) + + output.x_coordinates, output.y_coordinates, output.z_coordinates = coords + output.point_data.ShallowCopy(image_data.point_data) + output.cell_data.ShallowCopy(image_data.cell_data) + output.field_data.ShallowCopy(image_data.field_data) + + return output + + +def _coerce_shapes(*arrs): + """Coerce all argument arrays to have the same shape.""" + maxi = 0 + ndim = 0 + for i, arr in enumerate(arrs): + if arr is None: + continue + if arr.ndim > ndim: + ndim = arr.ndim + maxi = i + # print(arrs) + # if ndim != len(arrs) - (*arrs,).count(None): + # print(ndim, len(arrs)) + # raise ValueError + if ndim < 1: + raise ValueError + shape = arrs[maxi].shape + reshaped = [] + for arr in arrs: + if arr is not None and arr.shape != shape: + if arr.ndim < ndim: + arr = np.repeat([arr], shape[2 - maxi], axis=2 - maxi) + else: + raise ValueError + reshaped.append(arr) + return reshaped + + +def _points( + accessor, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + order: Optional[str] = "F", + scales: Optional[Dict] = None, +): + """Generate structured points as new array.""" + if order is None: + order = "F" + ndim = 3 - (x, y, z).count(None) + if ndim < 2: + if ndim == 1: + raise ValueError( + "One dimensional structured grids should be rectilinear grids." + ) + raise ValueError("You must specify at least two dimensions as X, Y, or Z.") + if x is not None: + x = accessor._get_array(x, scale=(scales and scales.get(x)) or 1) + if y is not None: + y = accessor._get_array(y, scale=(scales and scales.get(y)) or 1) + if z is not None: + z = accessor._get_array(z, scale=(scales and scales.get(z)) or 1) + arrs = _coerce_shapes(x, y, z) + x, y, z = arrs + arr = [a for a in arrs if a is not None][0] + points = np.zeros((arr.size, 3), dtype=arr.dtype) + if x is not None: + points[:, 0] = x.ravel(order=order) + if y is not None: + points[:, 1] = y.ravel(order=order) + if z is not None: + points[:, 2] = z.ravel(order=order) + shape = list(x.shape) + [1] * (3 - ndim) + return points, shape + + +def rectilinear( + accessor, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + order: Optional[str] = "C", + component: Optional[str] = None, + scales: Optional[Dict] = None, +): + if scales is None: + scales = {} + + ndim = 3 - (x, y, z).count(None) + if ndim < 1: + raise ValueError("You must specify at least one dimension as X, Y, or Z.") + + # Build dataset + dataset = vtkRectilinearGrid() + + if x is not None: + dataset.x_coordinates = accessor._get_array(x, scale=scales.get(x, 1)) + if y is not None: + dataset.y_coordinates = accessor._get_array(y, scale=scales.get(y, 1)) + if z is not None: + dataset.z_coordinates = accessor._get_array(z, scale=scales.get(z, 1)) + + # Update grid size + dataset.dimensions = [ + dataset.x_coordinates.size, + dataset.y_coordinates.size, + dataset.z_coordinates.size, + ] + + # Handle field + values = accessor.data + values_dim = values.ndim + if component is not None: + # if ndim < values.ndim and values.ndim == ndim + 1: + # Assuming additional component array + dims = set(accessor._xarray.dims) + dims.discard(component) + print("values changed - transpose") + values = accessor._xarray.transpose( + *dims, component, transpose_coords=True + ).values + values = values.reshape((-1, values.shape[-1]), order=order) + warnings.warn( + DataCopyWarning( + "Made a copy of the multicomponent array - VTK data not shared with xarray." + ) + ) + ndim += 1 + else: + print("values changed - ravel") + values = values.ravel(order=order) + + # Validate dimensions of field + if values_dim != ndim: + msg = f"Dimensional mismatch between specified X, Y, Z coords and dimensionality of DataArray ({ndim} vs {values_dim})" + if ndim > values_dim: + raise ValueError( + f"{msg}. Too many coordinate dimensions specified leave out Y and/or Z." + ) + raise ValueError( + f"{msg}. Too few coordinate dimensions specified. Be sure to specify Y and/or Z or reduce the dimensionality of the DataArray by indexing along non-spatial coordinates like Time." + ) + + array_name = str(accessor._xarray.name or "data") + dataset.point_data[array_name] = values + return dataset + + +def structured( + accessor, + x: Optional[str] = None, + y: Optional[str] = None, + z: Optional[str] = None, + order: Optional[str] = "F", + component: Optional[str] = None, + scales: Optional[Dict] = None, +): + if scales is None: + scales = {} + + points, shape = _points(accessor, x=x, y=y, z=z, order=order, scales=scales) + + dataset = vtkStructuredGrid() + dataset.SetDimensions(shape) + dataset.points = points + dataset.point_data[accessor._xarray.name or "data"] = accessor.data.ravel( + order=order + ) + + return dataset diff --git a/pan3d/xarray/errors.py b/pan3d/xarray/errors.py new file mode 100644 index 00000000..9580902a --- /dev/null +++ b/pan3d/xarray/errors.py @@ -0,0 +1,2 @@ +class DataCopyWarning(Warning): + pass diff --git a/pan3d/xarray/io.py b/pan3d/xarray/io.py new file mode 100644 index 00000000..e9a10ff4 --- /dev/null +++ b/pan3d/xarray/io.py @@ -0,0 +1,135 @@ +from pathlib import Path +import warnings + +import numpy as np +import xarray as xr +from xarray.backends import BackendEntrypoint + +from pan3d.xarray.errors import DataCopyWarning + +from vtkmodules.vtkIOXML import ( + vtkXMLImageDataReader, + vtkXMLRectilinearGridReader, + vtkXMLStructuredGridReader, +) +from vtkmodules.vtkIOLegacy import vtkDataSetReader +from vtkmodules.vtkCommonDataModel import vtkDataObject + +READERS = { + ".vti": vtkXMLImageDataReader, + ".vtr": vtkXMLRectilinearGridReader, + ".vts": vtkXMLStructuredGridReader, + ".vtk": vtkDataSetReader, +} + + +def read(file_path): + reader = READERS[Path(file_path).suffix](file_name=file_path) + # reader.SetFileName(file_path) + return reader() + + +def rectilinear_grid_to_dataset(mesh): + dims = list(mesh.dimensions) + dims = dims[-1:] + dims[:-1] + return xr.Dataset( + { + name: (["z", "x", "y"], mesh.point_data[name].ravel().reshape(dims)) + for name in mesh.point_data.keys() + }, + coords={ + "x": (["x"], mesh.x_coordinates), + "y": (["y"], mesh.y_coordinates), + "z": (["z"], mesh.z_coordinates), + }, + ) + + +def image_data_to_dataset(mesh): + origin = mesh.origin + spacing = mesh.spacing + extent = mesh.extent + + def gen_coords(i): + return np.array( + [ + origin[i] + (spacing[i] * v) + for v in range(extent[i * 2], extent[i * 2 + 1] + 1) + ], + dtype=np.double, + ) + + dims = list(mesh.dimensions) + return xr.Dataset( + { + name: (["z", "x", "y"], mesh.point_data[name].ravel().reshape(dims)) + for name in mesh.point_data.keys() + }, + coords={ + "x": (["x"], gen_coords(0)), + "y": (["y"], gen_coords(1)), + "z": (["z"], gen_coords(2)), + }, + ) + + +def structured_grid_to_dataset(mesh): + warnings.warn( + DataCopyWarning( + "StructuredGrid dataset engine duplicates data - VTK data not shared with xarray." + ) + ) + + return xr.Dataset( + { + name: ( + ["xi", "yi", "zi"], + mesh.point_data[name].ravel().reshape(mesh.dimensions), + ) + for name in mesh.point_data.keys() + }, + coords={ + "x": (["xi", "yi", "zi"], mesh.x_coordinates), + "y": (["xi", "yi", "zi"], mesh.y_coordinates), + "z": (["xi", "yi", "zi"], mesh.z_coordinates), + }, + ) + + +DATASET_TO_XARRAY = { + "vtkRectilinearGrid": rectilinear_grid_to_dataset, + "vtkImageData": image_data_to_dataset, + "vtkStructuredGrid": structured_grid_to_dataset, +} + + +def dataset_to_xarray(dataset): + if isinstance(dataset, vtkDataObject): + for ds_type in DATASET_TO_XARRAY: + if dataset.IsA(ds_type): + return DATASET_TO_XARRAY[ds_type](dataset) + + raise TypeError( + f"pan3d is unable to generate an xarray DataSet from the {type(dataset)} VTK data type at this time." + ) + + +class VTKBackendEntrypoint(BackendEntrypoint): + def open_dataset( + self, + filename_or_obj, + *, + drop_variables=None, + ): + return dataset_to_xarray(read(filename_or_obj)) + + open_dataset_parameters = [ + "filename_or_obj", + "attrs", + ] + + def guess_can_open(self, filename_or_obj): + try: + return Path(filename_or_obj).suffix in READERS + except TypeError: + return False diff --git a/pyproject.toml b/pyproject.toml index 0e9c75a2..edb486e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,15 @@ authors = [ {name = "Kitware Inc."}, ] dependencies = [ - "aiohttp>=3.9", - "dask>=2023.10", - "fsspec>=2023.9", - "netCDF4>=1.6", - "pyvista>=0.43", - "pyvista-xarray>=0.1", + # viz + "vtk>=9.4.0rc3", + + # catalog handling "requests>=2.31", - "xarray>=2023.8", - "zarr>=2.16", + "aiohttp", # <= url catalog + + # XArray + "xarray[io, parallel]>=2023.8", ] requires-python = ">=3.9" readme = "docs/README.md" @@ -32,11 +32,11 @@ classifiers = [ ] [project.optional-dependencies] -geotrame = [ +viewer = [ "trame>=3.6", "trame-vtk>=2.6", + "trame-vtklocal", "trame-vuetify>=2.4", - "geovista>=0.4", ] esgf = [ "intake-esgf>=2024.1", @@ -49,11 +49,11 @@ pangeo = [ "gcsfs>=2024.2", ] all = [ - # viewer + # viewers/explorers "trame>=3.6", "trame-vtk>=2.6", + "trame-vtklocal", "trame-vuetify>=2.4", - "geovista>=0.4", # esgf "intake-esgf>=2024.1", @@ -67,13 +67,23 @@ all = [ ] [project.scripts] -geotrame = "pan3d.serve_geotrame:serve" -slice-explorer = "pan3d.explorers.slice_explorer:main" +xr-viewer = "pan3d.viewers.preview:main" +xr-catalog = "pan3d.viewers.catalog:main" +xr-slicer = "pan3d.explorers.slicer:main" [build-system] requires = ['setuptools', 'wheel'] build-backend = 'setuptools.build_meta' +[project.entry-points."xarray.backends"] +vtk = "pan3d.xarray.io:VTKBackendEntrypoint" + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::pan3d.xarray.errors.DataCopyWarning", +] + + [tool.setuptools.packages.find] where = ["."] @@ -81,6 +91,7 @@ where = ["."] pan3d = [ "**/module/serve/*.js", "**/ui/custom.css", + "**/ui/css/*", "**/utils/*.json" ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..598cd89b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest +from pathlib import Path + + +@pytest.fixture(scope="session") +def vtr_path(): + return str((Path(__file__).with_name("data") / "air_temperature.vtr").resolve()) + + +@pytest.fixture(scope="session") +def vts_path(): + return str((Path(__file__).with_name("data") / "structured.vts").resolve()) + + +@pytest.fixture(scope="session") +def vti_path(): + return str((Path(__file__).with_name("data") / "wavelet.vti").resolve()) diff --git a/tests/data/air_temperature.vtr b/tests/data/air_temperature.vtr new file mode 100644 index 00000000..f32800b4 --- /dev/null +++ b/tests/data/air_temperature.vtr @@ -0,0 +1,25 @@ + + + + + + + AQAAAACAAAC0FAAAbwkAAA==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 + + + + + + + AQAAAACAAADUAAAAfgAAAA==eJwNyFkGAlAAQNG3tHg/iUQikUgkEolEojRo0KBBgwYN2kdL63xd54aQjGGQiiGkNaNZzWleC1rUkpa1olWtaV0b2tSWtrWjXe1pP4bfQIcxJEbemCc85RnPecFLXvGaN7zlHe/5wEc+8ZkvfOUb3/nBT37xmz/8jX96xji2 + + + AQAAAACAAABkAAAASAAAAA==eJwNwyESQFAUAMA3sizLsmzmS7Isy7KoKL8oskM4hEM4hCPYndmIs484zO5uri7OTo4OdrY21laWFn4p4vXx9jK7pB9G9BEy + + + AQAAAACAAAAIAAAACwAAAA==eJxjYIAAAAAIAAE= + + + + + diff --git a/tests/data/structured.vts b/tests/data/structured.vts new file mode 100644 index 00000000..8c185776 --- /dev/null +++ b/tests/data/structured.vts @@ -0,0 +1,27 @@ + + + + + + + AQAAAACAAAAAZAAA7hsAAA==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 + + + 0 + + + 14.177446008 + + + + + + + diff --git a/tests/data/wavelet.vti b/tests/data/wavelet.vti new file mode 100644 index 00000000..3d4d9aa1 --- /dev/null +++ b/tests/data/wavelet.vti @@ -0,0 +1,14 @@ + + + + + + + AgAAAACAAAC0EAAAX0MAAOUOAAA=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 + + + + + + + diff --git a/tests/test_builder.py b/tests/test_builder.py index 4083e799..731fe3c0 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,208 +1,50 @@ import json -import tempfile from pathlib import Path -import pytest -from pan3d import DatasetBuilder +from pan3d.xarray.algorithm import vtkXArrayRectilinearSource + +ROOT_PATH = Path(__file__).parent.parent.resolve() + + +def read_config(path): + return json.loads((ROOT_PATH / path).read_text()) def test_import_config(): - builder = DatasetBuilder() - builder.import_config("examples/example_config_noaa.json") - # With slicing, data_array has shape (100, 100) - assert builder.data_array.size == 10000 + conf = read_config("examples/example_config_noaa.json") + builder = vtkXArrayRectilinearSource() + builder.load(conf) + + # check available arrays + assert set(builder.available_arrays) == set( + ["sea_ice_fraction", "analysed_sst", "mask", "analysis_error"] + ) + # check array size based on slicing + builder.arrays = ["mask"] + ds = builder() + assert ds.point_data["mask"].size == 3904 -def test_export_config(): - builder = DatasetBuilder() - import_path = "examples/example_config_xarray.json" - builder.import_config(import_path) - with tempfile.TemporaryDirectory() as temp_dir: - export_path = Path(temp_dir, "exported.json") - builder.export_config(export_path) - with open(import_path) as f: - imported = json.load(f) - with open(export_path) as f: - exported = json.load(f) - # Disregard ui section - imported.pop("ui", None) - exported.pop("ui", None) - assert imported == exported +def test_export_config(): + conf = read_config("examples/example_config_xarray.json") + builder = vtkXArrayRectilinearSource() + builder.load(conf) + input_conf = conf.get("dataset_config") + output_conf = builder.state.get("dataset_config") + input_conf["arrays"] = set(input_conf["arrays"]) + output_conf["arrays"] = set(output_conf["arrays"]) + assert input_conf == output_conf def test_setters(): - builder = DatasetBuilder() + builder = vtkXArrayRectilinearSource() - builder.dataset_info = {"source": "xarray", "id": "eraint_uvz"} - # builder will auto select the following: - # builder.data_array_name = 'z' - # builder.x = 'longitude' - # builder.y = 'latitude' - builder.z = "level" - builder.t = "month" + builder.load({"data_origin": {"source": "xarray", "id": "eraint_uvz"}}) builder.t_index = 1 - builder.slicing = {"longitude": [0, 90, 2]} - assert builder.dataset_info == {"source": "xarray", "id": "eraint_uvz"} - assert builder.dataset is not None - assert builder.data_array_name == "z" assert builder.x == "longitude" assert builder.y == "latitude" assert builder.z == "level" assert builder.t == "month" assert builder.t_index == 1 - - -def test_setters_invalid_values(): - builder = DatasetBuilder() - - # Setting wrong types - with pytest.raises(TypeError) as e: - builder.dataset_info = "foo" - assert str(e.value) == "Type of dataset_info must be Dict or None." - with pytest.raises(TypeError) as e: - builder.dataset = "foo" - assert str(e.value) == "Type of dataset must be xarray.Dataset or None." - with pytest.raises(TypeError) as e: - builder.data_array_name = 2 - assert str(e.value) == "Type of data_array_name must be str or None." - with pytest.raises(TypeError) as e: - builder.x = 2 - assert str(e.value) == "Type of x must be str or None." - with pytest.raises(TypeError) as e: - builder.y = 2 - assert str(e.value) == "Type of y must be str or None." - with pytest.raises(TypeError) as e: - builder.z = 2 - assert str(e.value) == "Type of z must be str or None." - with pytest.raises(TypeError) as e: - builder.t = 2 - assert str(e.value) == "Type of t must be str or None." - with pytest.raises(TypeError) as e: - builder.t_index = None - assert str(e.value) == "Type of t_index must be int." - with pytest.raises(TypeError) as e: - builder.slicing = "foo" - assert str(e.value) == "Type of slicing must be Dict or None." - - # Setting in the wrong order - with pytest.raises(ValueError) as e: - builder.t_index = 5 - assert str(e.value) == "Cannot set time index > 0 without setting t array first." - with pytest.raises(ValueError) as e: - builder.t = "foo" - assert str(e.value) == "Cannot set t without setting data array name first." - with pytest.raises(ValueError) as e: - builder.z = "foo" - assert str(e.value) == "Cannot set z without setting data array name first." - with pytest.raises(ValueError) as e: - builder.y = "foo" - assert str(e.value) == "Cannot set y without setting data array name first." - with pytest.raises(ValueError) as e: - builder.x = "foo" - assert str(e.value) == "Cannot set x without setting data array name first." - with pytest.raises(ValueError) as e: - builder.slicing = {"foo": [0, 1, 1]} - assert str(e.value) == "Cannot set slicing without setting data array name first." - with pytest.raises(ValueError) as e: - builder.data_array_name = "foo" - assert ( - str(e.value) == "Cannot set data array name without setting dataset info first." - ) - - # Setting wrong values for dataset_info - with pytest.raises(ValueError) as e: - builder.dataset_info = {} - assert str(e.value) == 'Dataset info must contain key "id" with string value.' - with pytest.raises(ValueError) as e: - builder.dataset_info = {"id": "foo", "source": "bar"} - assert ( - str(e.value) - == "Invalid source value. Must be one of [default, xarray, pangeo, esgf]." - ) - - # Set a valid value to proceed - builder.dataset_info = {"source": "xarray", "id": "eraint_uvz"} - - # Setting wrong values for data_array_name - with pytest.raises(ValueError) as e: - builder.data_array_name = "foo" - assert ( - str(e.value) == "foo does not exist on dataset. Must be one of ['z', 'u', 'v']." - ) - - # Set a valid value to proceed - builder.data_array_name = "v" - - acceptable_coord_names = ["latitude", "level", "longitude", "month"] - # Setting wrong values for x, y, z, t - with pytest.raises(ValueError) as e: - builder.x = "foo" - assert ( - str(e.value) - == f"foo does not exist on data array. Must be one of {acceptable_coord_names}." - ) - with pytest.raises(ValueError) as e: - builder.y = "foo" - assert ( - str(e.value) - == f"foo does not exist on data array. Must be one of {acceptable_coord_names}." - ) - with pytest.raises(ValueError) as e: - builder.z = "foo" - assert ( - str(e.value) - == f"foo does not exist on data array. Must be one of {acceptable_coord_names}." - ) - with pytest.raises(ValueError) as e: - builder.t = "foo" - assert ( - str(e.value) - == f"foo does not exist on data array. Must be one of {acceptable_coord_names}." - ) - - # Set valid values to proceed - builder.x = "longitude" - builder.y = "latitude" - builder.z = "level" - builder.t = "month" - - # Setting wrong values for t_index - with pytest.raises(ValueError) as e: - builder.t_index = -1 - assert str(e.value) == "Time index must be a positive integer." - with pytest.raises(ValueError) as e: - builder.t_index = 100 - assert str(e.value) == "Time index must be less than size of t coordinate (2)." - - # Setting wrong values for slicing - with pytest.raises(ValueError) as e: - builder.slicing = {0: []} - assert str(e.value) == "Keys in slicing must be strings." - with pytest.raises(ValueError) as e: - builder.slicing = {"foo": []} - assert ( - str(e.value) - == "Values in slicing must be lists of 3 integers ([start, stop, step])." - ) - with pytest.raises(ValueError) as e: - builder.slicing = {"foo": [0, 1, 1]} - assert ( - str(e.value) - == f"Key foo not found in data array. Must be one of {acceptable_coord_names}." - ) - with pytest.raises(ValueError) as e: - builder.slicing = {"month": [-1, 10, 10]} - assert ( - str(e.value) - == "Value [-1, 10, 10] not applicable for Key month. Step value must be <= 2." - ) - - -def test_import_error(): - # This will only succeed in an environment where - # pan3d is installed but pan3d[geotrame] is not installed. - builder = DatasetBuilder() - with pytest.raises(ImportError): - builder.viewer diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 5226738c..c283e5f0 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1,40 +1,13 @@ -from pan3d import DatasetBuilder -from pan3d import DatasetViewer - - -def push_builder_state(builder): - builder.dataset_info = {"source": "xarray", "id": "eraint_uvz"} - builder.z = "level" - builder.t = "month" - builder.t_index = 1 - builder.slicing = {"longitude": [0, 90, 1], "latitude": [0, 100, 1]} - - -def push_viewer_state(viewer): - # For state updates with callbacks, - # Ensure the callbacks occur in the correct order - viewer.state.update(dict(dataset_info={"source": "xarray", "id": "eraint_uvz"})) - viewer.state.flush() - viewer.state.update( - dict( - da_active="z", - ) - ) - viewer.state.flush() - viewer.state.update( - dict( - da_z="level", - da_t="month", - da_t_index=1, - ) - ) - viewer.state.flush() +from pan3d.viewers.preview import XArrayViewer def assert_builder_state(builder): - assert builder.dataset_info == {"source": "xarray", "id": "eraint_uvz"} - assert builder.dataset is not None - assert builder.data_array_name == "z" + assert builder.state.get("data_origin") == { + "source": "xarray", + "id": "eraint_uvz", + "order": "C", + } + assert builder.arrays == ["z"] assert builder.x == "longitude" assert builder.y == "latitude" assert builder.z == "level" @@ -42,143 +15,63 @@ def assert_builder_state(builder): assert builder.t_index == 1 -def assert_viewer_state(viewer): - assert viewer.state.dataset_info == {"source": "xarray", "id": "eraint_uvz"} - assert viewer.state.dataset_ready - assert viewer.state.da_active == "z" - assert viewer.state.da_x == "longitude" - assert viewer.state.da_y == "latitude" - assert viewer.state.da_z == "level" - assert viewer.state.da_t == "month" - assert viewer.state.da_t_index == 1 - assert viewer.state.da_size == "211 KB" - assert viewer.state.da_vars == [ - {"name": "z", "id": 0}, - {"name": "u", "id": 1}, - {"name": "v", "id": 2}, - ] - - def test_ui_state(): - viewer = DatasetViewer(server="ui", state=dict(render_auto=False)) - push_viewer_state(viewer) - - viewer._coordinate_toggle_expansion("month") - viewer._coordinate_toggle_expansion("level") - - assert not viewer.state.ui_loading - assert not viewer.state.ui_main_drawer - assert not viewer.state.ui_axis_drawer - assert viewer.state.ui_unapplied_changes - assert viewer.state.ui_error_message is None - assert viewer.state.ui_more_info_link is None - assert viewer.state.ui_expanded_coordinates == ["month", "level"] + viewer = XArrayViewer(server="test") + + # fake server started to trigger callback + viewer.disable_rendering = True + viewer.state.ready() + + viewer.import_state( + { + "data_origin": {"source": "xarray", "id": "eraint_uvz"}, + "dataset_config": { + "arrays": ["z"], + "t_index": 1, + "slices": { + "longitude": [0, 90, 1], + "latitude": [0, 100, 1], + }, + }, + } + ) + assert_builder_state(viewer.source) + assert viewer.state.axis_names == ["longitude", "latitude", "level"] + assert viewer.state.data_origin_error is False + assert viewer.state.data_origin_source == "xarray" + assert viewer.state.data_origin_id == "eraint_uvz" + assert viewer.state.load_button_text == "Loaded" + assert viewer.state.can_load is False + assert viewer.state.show_data_information is True + assert len(viewer.state.t_labels) == 2 def test_render_options_state(): - viewer = DatasetViewer(server="render_options", state=dict(render_auto=False)) - push_viewer_state(viewer) - - viewer.set_render_scales( - x=2, - y=3, - z=4, - ) - viewer.set_render_options( - colormap="magma", - transparency=True, - transparency_function="linear_r", - scalar_warp=True, - cartographic=False, # not compatible with this 4D data - # geovista GeoPlotter includes a check for GPU availability, - # which fails on GH Actions. Disable render in this function. - render=False, + viewer = XArrayViewer(server="render_options") + + # fake server started to trigger callback + viewer.disable_rendering = True + viewer.state.ready() + + viewer.import_state( + { + "data_origin": {"source": "xarray", "id": "eraint_uvz"}, + "dataset_config": { + "arrays": ["z"], + "t_index": 1, + }, + } ) + with viewer.state: + viewer.state.update( + { + "scale_x": 0.1, + "scale_y": 0.2, + "scale_z": 0.3, + "color_by": "z", + } + ) - assert viewer.state.render_x_scale == 2 - assert viewer.state.render_y_scale == 3 - assert viewer.state.render_z_scale == 4 - assert viewer.state.render_colormap == "magma" - assert viewer.state.render_transparency - assert viewer.state.render_transparency_function == "linear_r" - assert viewer.state.render_scalar_warp - assert not viewer.state.render_cartographic - - -def test_viewer_export(): - viewer = DatasetViewer(server="export", state=dict(render_auto=False)) - push_viewer_state(viewer) - - viewer.state.update(dict(ui_action_name="Export")) - viewer.state.flush() - - # Export action will complete on flush and reset action state - assert viewer.state.ui_action_name == "Export" - assert viewer.state.ui_action_message is None - assert viewer.state.ui_action_config_file is None - - assert viewer.state.state_export["data_origin"] == { - "source": "xarray", - "id": "eraint_uvz", - } - assert viewer.state.state_export["data_array"]["name"] == "z" - assert viewer.state.state_export["data_array"]["x"] == "longitude" - assert viewer.state.state_export["data_array"]["y"] == "latitude" - assert viewer.state.state_export["data_array"]["z"] == "level" - assert viewer.state.state_export["data_array"]["t"] == "month" - assert viewer.state.state_export["data_array"]["t_index"] == 1 - assert viewer.state.state_export["data_slices"]["longitude"] == [0, 480, 4] - assert viewer.state.state_export["data_slices"]["latitude"] == [0, 241, 2] - assert viewer.state.state_export["data_slices"]["level"] == [0, 3, 1] - assert viewer.state.state_export["data_slices"]["month"] == [0, 2, 1] - assert not viewer.state.state_export["ui"]["main_drawer"] - assert not viewer.state.state_export["ui"]["axis_drawer"] - assert viewer.state.state_export["ui"]["unapplied_changes"] - assert viewer.state.state_export["ui"]["error_message"] is None - assert viewer.state.state_export["ui"]["more_info_link"] is None - assert viewer.state.state_export["ui"]["expanded_coordinates"] == [] - - -def test_layout(): - from trame_vuetify.ui.vuetify3 import VAppLayout - - builder = DatasetBuilder(server="layout") - assert isinstance(builder.viewer.ui, VAppLayout) - - -def test_sync_to_viewer_from_builder(): - builder = DatasetBuilder(server="from_builder") - viewer = builder.viewer - viewer.state.render_auto = False - push_builder_state(builder) - - assert_builder_state(builder) - assert_viewer_state(viewer) - - -def test_sync_during_viewer_creation(): - builder = DatasetBuilder(server="from_creation") - push_builder_state(builder) - assert_builder_state(builder) - - # Viewer created last, state should sync during initialization - viewer = builder.viewer - viewer.state.render_auto = False - assert_viewer_state(viewer) - - -def test_sync_from_viewer_ui_functions(): - builder = DatasetBuilder(server="from_ui_funcs") - viewer = builder.viewer - viewer.state.render_auto = False - push_viewer_state(viewer) - - viewer._coordinate_select_axis("level", None, "da_z") - viewer._coordinate_select_axis("month", None, "da_t") - viewer._coordinate_change_slice("longitude", "start", 0) - viewer._coordinate_change_slice("longitude", "stop", 89) - viewer._coordinate_change_bounds("latitude", [0, 99]) - - viewer.state.flush() - assert_builder_state(builder) - assert_viewer_state(viewer) + assert_builder_state(viewer.source) + assert viewer.actor.GetScale() == (0.1, 0.2, 0.3) + assert viewer.mapper.array_name == "z" diff --git a/tests/test_xarray.py b/tests/test_xarray.py new file mode 100644 index 00000000..aff25dff --- /dev/null +++ b/tests/test_xarray.py @@ -0,0 +1,90 @@ +import numpy as np +import xarray as xr +from pan3d.xarray.io import dataset_to_xarray, read as vtk_read +from pan3d.xarray.datasets import imagedata_to_rectilinear + + +def test_engine_is_available(): + assert "vtk" in xr.backends.list_engines() + + +def test_read_vtr(vtr_path): + ds = xr.open_dataset(vtr_path, engine="vtk") + truth = vtk_read(vtr_path) + assert np.allclose(ds["air"].values.ravel(), truth.point_data["air"].ravel()) + assert np.allclose(ds["x"].values, truth.x_coordinates) + assert np.allclose(ds["y"].values, truth.y_coordinates) + assert np.allclose(ds["z"].values, truth.z_coordinates) + accessor_ds = ds["air"].vtk.dataset(x="x", y="y", z="z") + assert accessor_ds == truth + + +def test_read_vti(vti_path): + ds = xr.open_dataset(vti_path, engine="vtk") + truth = vtk_read(vti_path) + truth_r = imagedata_to_rectilinear(truth) + assert np.allclose(ds["RTData"].values.ravel(), truth.point_data["RTData"].ravel()) + assert np.allclose(ds["x"].values, truth_r.x_coordinates) + assert np.allclose(ds["y"].values, truth_r.y_coordinates) + assert np.allclose(ds["z"].values, truth_r.z_coordinates) + + assert ds["RTData"].vtk.dataset(x="x", y="y", z="z") == truth_r + + +def test_read_vts(vts_path): + ds = xr.open_dataset(vts_path, engine="vtk") + truth = vtk_read(vts_path) + assert np.allclose( + ds["Elevation"].values.ravel(), truth.point_data["Elevation"].ravel() + ) + assert np.allclose(ds["x"].values, truth.x_coordinates) + assert np.allclose(ds["y"].values, truth.y_coordinates) + assert np.allclose(ds["z"].values, truth.z_coordinates) + accessor_ds = ds["Elevation"].vtk.dataset(x="x", y="y", z="z") + accessor_ds == truth + + +def test_convert_vtr(vtr_path): + truth = vtk_read(vtr_path) + ds = dataset_to_xarray(truth) + mesh = ds["air"].vtk.dataset(x="x", y="y", z="z") + assert np.array_equal(ds["air"].values.ravel(), truth.point_data["air"].ravel()) + assert np.may_share_memory( + ds["air"].values.ravel(), truth.point_data["air"].ravel() + ) + assert np.array_equal(mesh.x_coordinates, truth.x_coordinates) + assert np.array_equal(mesh.y_coordinates, truth.y_coordinates) + assert np.array_equal(mesh.z_coordinates, truth.z_coordinates) + assert np.may_share_memory(mesh.z_coordinates, truth.z_coordinates) + assert mesh == truth + + +def test_convert_vti(vti_path): + truth = vtk_read(vti_path) + ds = dataset_to_xarray(truth) + truth_r = imagedata_to_rectilinear(truth) + mesh = ds["RTData"].vtk.dataset(x="x", y="y", z="z") + assert np.array_equal( + ds["RTData"].values.ravel(), truth.point_data["RTData"].ravel() + ) + assert np.may_share_memory( + ds["RTData"].values.ravel(), truth.point_data["RTData"].ravel() + ) + assert np.array_equal(mesh.x_coordinates, truth_r.x_coordinates) + assert np.array_equal(mesh.y_coordinates, truth_r.y_coordinates) + assert np.array_equal(mesh.z_coordinates, truth_r.z_coordinates) + assert mesh == truth_r + + +def test_convert_vts(vts_path): + print("=" * 10, "test_convert_vts", "=" * 10) + truth = vtk_read(vts_path) + ds = dataset_to_xarray(truth) + assert np.array_equal( + ds["Elevation"].values.ravel(), truth.point_data["Elevation"].ravel() + ) + assert np.may_share_memory( + ds["Elevation"].values.ravel(), truth.point_data["Elevation"].ravel() + ) + mesh = ds["Elevation"].vtk.dataset(x="x", y="y", z="z") + assert mesh == truth