├── .git-blame-ignore-revs ├── .github └── workflows │ ├── publish.yaml │ ├── pytest.yaml │ └── style.yaml ├── .gitignore ├── CITATIONS.bib ├── LICENSE-MIT ├── MANIFEST.in ├── README.md ├── data ├── Stuke-AA.json ├── Stuke-OE62.json ├── Stuke-QM9.json ├── Wang-df.csv └── autompg-df.csv ├── docs ├── README.md ├── images │ ├── barplot_reldiff.png │ ├── barplot_total.png │ ├── boxplot.png │ ├── cluster_by_drawing.png │ ├── cluster_by_drawing_after.png │ ├── cluster_by_drawing_replace_mode.png │ ├── cluster_by_drawing_replace_mode_after.png │ ├── cluster_by_input.png │ ├── cluster_by_input_after.png │ ├── cluster_reset.png │ ├── dark_mode.png │ ├── distribution_plot.png │ ├── download_plots_and_data_file.png │ ├── heatmap.png │ ├── histogram_cluster_comparison.png │ ├── lineplot.png │ ├── load_data_file.png │ ├── load_data_file_after.png │ ├── scatterplot_click.png │ ├── scatterplot_coloring.png │ ├── scatterplot_hover.png │ ├── scatterplot_init.png │ ├── scatterplot_jittering.png │ ├── smiles_input.png │ ├── table_columns_selection.png │ ├── table_select_row.png │ └── upload_data_file.png └── user_guide │ ├── clustering.md │ ├── data_files.md │ ├── plots.md │ └── plugin.md ├── plugin_xiplot_filetypes ├── README.md ├── pyproject.toml └── xiplot_filetypes │ └── __init__.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── test_plugin ├── pyproject.toml └── xiplot_test_plugin │ └── __init__.py ├── tests ├── __init__.py ├── conftest.py ├── test_barplot.py ├── test_boxplot.py ├── test_data.py ├── test_distplot.py ├── test_filetypes.py ├── test_heatmap.py ├── test_histogram.py ├── test_launch.py ├── test_lazyload.py ├── test_lineplot.py ├── test_plugin.py ├── test_scatterplot.py ├── test_smiles.py ├── test_table.py ├── test_utils.py └── util_test.py └── xiplot ├── __init__.py ├── __main__.py ├── app.py ├── assets ├── RDKit_minimal.js ├── RDKit_minimal.wasm ├── __init__.py ├── book.svg ├── dcc_style.css ├── favicon.ico ├── github.svg ├── html_components.css └── stylesheet.css ├── plots ├── __init__.py ├── barplot.py ├── boxplot.py ├── distplot.py ├── heatmap.py ├── histogram.py ├── lineplot.py ├── scatterplot.py ├── smiles.py └── table.py ├── plugin.py ├── setup.py ├── tabs ├── __init__.py ├── cluster.py ├── data.py ├── embedding.py ├── plots.py ├── plugins.py └── settings.py └── utils ├── __init__.py ├── auxiliary.py ├── cli.py ├── cluster.py ├── components.py ├── dataframe.py ├── io.py ├── layouts.py ├── regex.py ├── scatterplot.py ├── store.py └── table.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Format the codebase with black 2 | b8ee3039e15fe0a160ed20b65f27ce593761d178 3 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: publish 5 | 6 | on: 7 | release: 8 | branches: [master, main] 9 | types: [released] 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.x" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install build 24 | - name: Build package 25 | run: python -m build 26 | - name: Publish package 27 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.PYPI_API_TOKEN }} 31 | plugin: 32 | runs-on: ubuntu-latest 33 | permissions: 34 | id-token: write 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Set up Python 38 | uses: actions/setup-python@v3 39 | with: 40 | python-version: "3.x" 41 | - name: Install dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | python -m pip install build 45 | - name: Build plugin 46 | run: python -m build plugin_xiplot_filetypes 47 | - name: Publish plugin 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | with: 50 | packages-dir: plugin_xiplot_filetypes/dist 51 | skip-existing: true 52 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: Test xiplot 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | paths: ["**.py", "pyproject.toml", "requirements*.txt"] 8 | pull_request: 9 | branches: [main] 10 | paths: ["**.py", "pyproject.toml", "requirements*.txt"] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11"] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up chome 23 | run: | 24 | wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add 25 | echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list 26 | sudo apt-get update 27 | sudo apt-get install libnss3-dev google-chrome-stable 28 | 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v3 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Install Python dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | python -m pip install -r requirements.txt -r requirements-dev.txt 38 | 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | 43 | - name: Build package 44 | run: | 45 | rm -rf dist 46 | python -m build . 47 | 48 | - name: Install package 49 | run: | 50 | cd dist 51 | python -m pip install *-*.whl 52 | python -c "import xiplot.app" 53 | xiplot --help 54 | cd .. 55 | -------------------------------------------------------------------------------- /.github/workflows/style.yaml: -------------------------------------------------------------------------------- 1 | name: Check the code style 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | paths: ["**.py"] 8 | pull_request: 9 | branches: [main] 10 | paths: ["**.py"] 11 | 12 | jobs: 13 | black: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: "3.7" 21 | - name: Install black 22 | run: pip install black 23 | - name: Run the black formatter 24 | run: black --diff --check . 25 | isort: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: "3.7" 33 | - name: Install isort 34 | run: pip install isort 35 | - name: Run the isort linter 36 | run: isort --diff --check . 37 | flake8: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Set up Python 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: "3.7" 45 | - name: Install flake8 46 | run: pip install pyproject-flake8 47 | - name: Run the flake8 linter 48 | run: pflake8 . 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # OWN 132 | .vscode/ 133 | *.tmp 134 | Untitled.ipynb 135 | data/ 136 | uploads/ 137 | node_modules 138 | plugins/*.whl 139 | -------------------------------------------------------------------------------- /CITATIONS.bib: -------------------------------------------------------------------------------- 1 | @inproceedings{tanaka2023xiplot, 2 | title = {$\chi$iplot: Web-First Visualisation Platform for Multidimensional Data}, 3 | shorttitle = {$\chi$iplot}, 4 | booktitle = {Machine Learning and Knowledge Discovery in Databases: Applied Data Science and Demo Track}, 5 | author = {Tanaka, Akihiro and Tyree, Juniper and Björklund, Anton and Mäkelä, Jarmo and Puolamäki, Kai}, 6 | editor = {De Francisci Morales, Gianmarco and Perlich, Claudia and Ruchansky, Natali and Kourtellis, Nicolas and Baralis, Elena and Bonchi, Francesco}, 7 | year = {2023}, 8 | month = {09}, 9 | series = {Lecture Notes in Computer Science}, 10 | pages = {335-339}, 11 | publisher = {Springer Nature Switzerland}, 12 | address = {Cham}, 13 | doi = {10.1007/978-3-031-43430-3_26}, 14 | isbn = {978-3-031-43430-3} 15 | } 16 | 17 | @misc{tanaka2023xiplotarxiv, 18 | title = {$\chi$iplot: Web-First Visualisation Platform for Multidimensional Data}, 19 | shorttitle = {$\chi$iplot}, 20 | author = {Tanaka, Akihiro and Tyree, Juniper and Bj{\"o}rklund, Anton and M{\"a}kel{\"a}, Jarmo and Puolam{\"a}ki, Kai}, 21 | year = {2023}, 22 | month = {06}, 23 | number = {arXiv:2306.12110}, 24 | eprint = {2306.12110}, 25 | archiveprefix = {arXiv}, 26 | primaryclass = {cs.HC} 27 | publisher = {{arXiv}}, 28 | doi = {10.48550/arXiv.2306.12110}, 29 | url = {http://arxiv.org/abs/2306.12110} 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Akihiro Tanaka and Juniper Tyree 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include xiplot/assets/* 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # χiplot 2 | 3 | χiplot, pronounced like "kaiplot"[^1], is a web-first visualisation platform for multidimensional data, implemented in the `xiplot` Python package. 4 | 5 | [^1]: Pronouncing χiplot like "xaiplot" is also recognised. 6 | 7 | ## Description 8 | 9 | χiplot is built on top of the [`dash`](https://github.com/plotly/dash) framework. The goal of the χiplot is to explore new insights from the collected data and to make data exploring user-friendly and intuitive. 10 | χiplot can be used without installation with the [WASM-based browser version](https://edahelsinki.fi/xiplot). 11 | 12 | You can find more details in the [user guide](docs/README.md) and in the paper: 13 | 14 | > Tanaka, A., Tyree, J., Björklund, A., Mäkelä, J., Puolamäki, K. (2023). 15 | > __χiplot: Web-First Visualisation Platform for Multidimensional Data.__ 16 | > Machine Learning and Knowledge Discovery in Databases: Applied Data Science and Demo Track, Lecture Notes in Computer Science, pp. 335-339. [DOI: 10.1007/978-3-031-43430-3_26](https://doi.org/10.1007/978-3-031-43430-3_26) 17 | 18 | For a quick demonstration, see [the video](https://helsinkifi-my.sharepoint.com/:v:/g/personal/tanakaki_ad_helsinki_fi/EcIIGy0bfP5FlW-0Lr4AMEYBbKoyuo6u7px3zu_K5Vk4xw?e=TPGGf8&nav=eyJyZWZlcnJhbEluZm8iOnsicmVmZXJyYWxBcHAiOiJTdHJlYW1XZWJBcHAiLCJyZWZlcnJhbFZpZXciOiJTaGFyZURpYWxvZyIsInJlZmVycmFsQXBwUGxhdGZvcm0iOiJXZWIiLCJyZWZlcnJhbE1vZGUiOiJ2aWV3In19) or try the [WASM version](https://edahelsinki.fi/xiplot). 19 | 20 | ## Screenshot 21 | 22 | ![Screenshot of xiplot](docs/images/cluster_by_drawing.png#gh-light-mode-only) 23 | ![Screenshot of xiplot](docs/images/dark_mode.png#gh-dark-mode-only) 24 | 25 | ## Installation free 26 | 27 | You can try out the installation free WASM version of χiplot at [edahelsinki.fi/xiplot](https://edahelsinki.fi/xiplot). Note that all data processing happens locally in **your** browser nothing is sent to a server. 28 | 29 | Please refer to the [wasm](https://github.com/edahelsinki/xiplot/tree/wasm#readme) branch for more information on how the WASM version is implemented 30 | 31 | ## Installation 32 | 33 | χiplot can also be installed locally with: `pip install xiplot`. 34 | To start the local server run `xiplot` in a terminal (with same Python environment venv/conda/... in the PATH). 35 | 36 | Or to use the latest, in-development, version clone this repository. 37 | To install the dependencies run `pip install -e .` or `pip install -r requirements.txt`. 38 | To start the local server run `python -m xiplot`. 39 | 40 | For more options run `xiplot --help`. 41 | 42 | ## Funding 43 | 44 | The `xiplot` application was created by [Akihiro Tanaka](https://github.com/TanakaAkihiro) and [Juniper Tyree](https://github.com/juntyr) as part of their summer internships in Kai Puolamäki's [Exploratory Data Analysis group](https://github.com/edahelsinki) at the University of Helsinki. 45 | 46 | Akihiro's internpship was paid for by the Academy of Finland (decision 346376) with funding associated with the VILMA Centre of Excellence. Juniper's internship was paid for by "Future Makers Funding Program 2018 of the Technology Industries of Finland Centennial Foundation, and the Jane and Aatos Erkko Foundation", with funding associated with the Atmospheric AI programme of the Finnish Center for Artificial Intelligence. 47 | 48 | ## License 49 | 50 | The `main` branch of the `xiplot` repository is licensed under the MIT License ([`LICENSE-MIT`](LICENSE-MIT) or http://opensource.org/licenses/MIT). 51 | 52 | ## Contribution 53 | 54 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be licensed as described above, without any additional terms or conditions. 55 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # User guide 2 | 3 | This is a user guide that has a documentation of features of the χiplot. 4 | By reading this user guide, users can have a better understanding of things that are possible to do in χiplot. 5 | 6 | ## Data files 7 | 8 | In [data_files.md](user_guide/data_files.md), you can find how to load example data files, upload your own data files and download the data files and save the plots and cluster 9 | information into your device. 10 | 11 | In addition, you can find the format that is used to save the plots and data into a `.tar` file. 12 | 13 | ## Plots 14 | 15 | In [plots.md](user_guide/plots.md), you can find how the plots are connected with each other and how to interact with them. 16 | 17 | ## Clustering 18 | 19 | In [clustering.md](user_guide/clustering.md), you can find how to create clusters in χiplot. 20 | 21 | ## Plugin 22 | 23 | In [plugin.md](user_guide/plugin.md), you can learn how to create plugins for χiplot. 24 | -------------------------------------------------------------------------------- /docs/images/barplot_reldiff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/barplot_reldiff.png -------------------------------------------------------------------------------- /docs/images/barplot_total.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/barplot_total.png -------------------------------------------------------------------------------- /docs/images/boxplot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/boxplot.png -------------------------------------------------------------------------------- /docs/images/cluster_by_drawing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_drawing.png -------------------------------------------------------------------------------- /docs/images/cluster_by_drawing_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_drawing_after.png -------------------------------------------------------------------------------- /docs/images/cluster_by_drawing_replace_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_drawing_replace_mode.png -------------------------------------------------------------------------------- /docs/images/cluster_by_drawing_replace_mode_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_drawing_replace_mode_after.png -------------------------------------------------------------------------------- /docs/images/cluster_by_input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_input.png -------------------------------------------------------------------------------- /docs/images/cluster_by_input_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_by_input_after.png -------------------------------------------------------------------------------- /docs/images/cluster_reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/cluster_reset.png -------------------------------------------------------------------------------- /docs/images/dark_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/dark_mode.png -------------------------------------------------------------------------------- /docs/images/distribution_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/distribution_plot.png -------------------------------------------------------------------------------- /docs/images/download_plots_and_data_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/download_plots_and_data_file.png -------------------------------------------------------------------------------- /docs/images/heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/heatmap.png -------------------------------------------------------------------------------- /docs/images/histogram_cluster_comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/histogram_cluster_comparison.png -------------------------------------------------------------------------------- /docs/images/lineplot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/lineplot.png -------------------------------------------------------------------------------- /docs/images/load_data_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/load_data_file.png -------------------------------------------------------------------------------- /docs/images/load_data_file_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/load_data_file_after.png -------------------------------------------------------------------------------- /docs/images/scatterplot_click.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/scatterplot_click.png -------------------------------------------------------------------------------- /docs/images/scatterplot_coloring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/scatterplot_coloring.png -------------------------------------------------------------------------------- /docs/images/scatterplot_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/scatterplot_hover.png -------------------------------------------------------------------------------- /docs/images/scatterplot_init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/scatterplot_init.png -------------------------------------------------------------------------------- /docs/images/scatterplot_jittering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/scatterplot_jittering.png -------------------------------------------------------------------------------- /docs/images/smiles_input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/smiles_input.png -------------------------------------------------------------------------------- /docs/images/table_columns_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/table_columns_selection.png -------------------------------------------------------------------------------- /docs/images/table_select_row.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/table_select_row.png -------------------------------------------------------------------------------- /docs/images/upload_data_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/docs/images/upload_data_file.png -------------------------------------------------------------------------------- /docs/user_guide/clustering.md: -------------------------------------------------------------------------------- 1 | # Clustering 2 | 3 | χiplot has two ways to create clusters: by drawing on scatterplots or by input on "Cluster" tab. 4 | 5 | There are also two modes to draw clusters: replace mode and edit mode. 6 | 7 | ## Cluster drawing 8 | 9 | In order to draw clusters, the user needs to display a scatterplot first. After the scatterplot is displayed, 10 | there are two built-in features on Plotly's scatterplot to select points: Box Select and Lasso Select. After the selection, clusters are created. 11 | 12 | ### Replace mode 13 | 14 | On the "Cluster" tab, there is a toggle to select replace mode or edit mode. 15 | 16 | On the replace mode, "Selection Cluster" dropdown is pinned to the cluster #1. 17 | 18 | When clusters are made on replace mode, selected points are set to cluster #1 and the rest are set to cluster #2. 19 | 20 | ![cluster_replace](../images/cluster_by_drawing_replace_mode.png) 21 | 22 | ![cluster_replace_2](../images/cluster_by_drawing_replace_mode_after.png) 23 | 24 | ### Edit mode 25 | 26 | On the edit mode, the user can select a cluster from the "Selection Cluster" dropdown and add points to the cluster by drawing points from a scatterplot. 27 | 28 | Cluster everything cannot be modified. 29 | 30 | ![cluster_edit](../images/cluster_by_drawing.png) 31 | ![cluster_edit_2](../images/cluster_by_drawing_after.png) 32 | 33 | ## Clustering by input 34 | 35 | Go to the "Cluster" tab. 36 | 37 | Select an amount of clusters to create. 38 | 39 | Select features to calculate. 40 | 41 | Click the "Compute the clusters" button. 42 | 43 | ![cluster_input](../images/cluster_by_input.png) 44 | ![cluster_input_2](../images/cluster_by_input_after.png) 45 | 46 | "cluster amount" dropdown has integers from 2 to 9. 47 | 48 | "features" dropdown has only numeric columns of the dataset. 49 | 50 | The user can add multiple features by giving a regular expression string to the "features" dropdown and clicking the "Add features by regex" button. 51 | 52 | 53 | ## Reset clusters 54 | 55 | Click the "Reset the clusters" button to reset the clusters. 56 | 57 | ![cluster_reset](../images/cluster_reset.png) 58 | -------------------------------------------------------------------------------- /docs/user_guide/plots.md: -------------------------------------------------------------------------------- 1 | # Scatterplot 2 | 3 | In χiplot, scatterplots are playing the key role. They are connected to most of the other plots either directly (by hovering or clicking) or 4 | indirectly (by drawing clusters). 5 | 6 | ## Control of scatterplots 7 | 8 | There are four dropdowns and one slider in the control of the scatterplots. 9 | 10 | The first two dropdowns change the axes of the scatterplot. The dropdowns have only columns, whose datatypes are numerical (e.g. a column weight is 11 | numerical but a column car-name is not numerical). 12 | 13 | ![scatterplot_axes](../images/scatterplot_init.png) 14 | 15 | The latter two dropdowns modify the color or the shape of the points of the scatterplot. The default value of the color dropdown is "Clusters". 16 | (The scatterplot's color on the above image has not changed, since clusters are not created yet) 17 | 18 | ![scatterplot_coloring](../images/scatterplot_coloring.png) 19 | 20 | The slider in the bottom is for jittering. Jittering seperates the points from each other, so that overlapped points can be recognized. 21 | 22 | ![scatterplot_jitter](../images/scatterplot_jittering.png) 23 | 24 | ## Hovering over points 25 | 26 | If the dataset has a column for SMILES strings, the user can display stick structure of the molecule on the SMILES plot by hovering over points on 27 | scatterplots. The dropdown value of the SMILES plot should be "hover". 28 | 29 | ![scatterplot_hover](../images/scatterplot_hover.png) 30 | 31 | ## Click on points 32 | 33 | As with the hovering, if the dataset has a column for SMILES strings, the stick structure is displayable by clicking on the points of the scatterplot. 34 | The dropdown value of the SMILES plot should be "click". 35 | 36 | In addition, when the user clicks a point on a scatterplot, the corresponding row on data tables is marked as selected and brought to 37 | the top of the data table. Clicked points are displayed larger than the other points and they are black. 38 | 39 | ![scatterplot_click](../images/scatterplot_click.png) 40 | 41 | ## Clustering 42 | 43 | See the ![clustering.md](clustering.md) 44 | 45 | 46 | # Line plot 47 | 48 | ![lineplot](../images/lineplot.png) 49 | 50 | The line plot draws a line on a 2D plane. 51 | 52 | There are three dropdowns; two for the axes to select the (numerical) variables and one to select the (optional, categorical) color groups. 53 | You can hover and click on the plot just like in the [scatterplot](#scatterplot). 54 | 55 | 56 | # Histogram 57 | 58 | ## Control of histogram 59 | 60 | There are two dropdowns in the control of a histogram. 61 | 62 | The first dropdown changes the value of the x axis. The dropdown has only numerical columns as selectable options. 63 | 64 | The second one is a multi-selectable dropdown, in which the user can select multiple clusters to display on the histogram. As a default and when the dropdown is empty, all clusters except the option "all" is displayed on the histogram. 65 | 66 | ![histogram](../images/histogram_cluster_comparison.png) 67 | 68 | 69 | # Distribution plot 70 | 71 | ![distribution plot](../images/distribution_plot.png) 72 | 73 | The distribution plot shows a smoothed density distribution of a variable. 74 | 75 | There are two dropdowns; one for the (numerical) x-axis and one for the (optional, categorical) color groups. 76 | You can hover and click on the markers below, just like in the [scatterplot](#scatterplot). 77 | 78 | 79 | # Heatmap 80 | 81 | ![heatmap](../images/heatmap.png) 82 | 83 | There is a slider in the control of a heatmap. It changes the amount of clusters to set. The default value is 2. 84 | 85 | 86 | # Barplot 87 | 88 | Barplot displays 10 highest bars on the barplot. 89 | 90 | ## Control of barplot 91 | 92 | There are four dropdowns in the control of a barplot. 93 | 94 | The first two dropdowns change the axes of the barplot. The dropdown of the x axis has all columns except float type columns. The dropdown of the y axis has only numerical columns and "frequency" by default value. "frequency" shows how often a particular value appears in a dataset. 95 | 96 | The third dropdown is a multi-selectable dropdown, in which the user can select multiple clusters to display on the barplot. As a default and when the dropdown is empty, all clusters except the option "all" is displayed on the barplot. 97 | 98 | The fourth dropdown changes the order of the bar groups. 99 | 100 | "reldiff" is the relative difference of the bar groups. Ten highest relative difference bar groups are displayed. 101 | ![barplot_reldiff](../images/barplot_reldiff.png) 102 | 103 | "total" is the sum of the values of the bar groups. Ten highest total bar groups are displayed. 104 | ![barplot_total](../images/barplot_total.png) 105 | 106 | 107 | # Box plot 108 | 109 | ![box plot](../images/boxplot.png) 110 | 111 | The box plot shows the distribution of a variable in a box, violin, or strip plot. 112 | 113 | There are four dropdowns; one (numerical) variable for the the y-axis, one (optional, categorical) variable for the x-axis, one (optional, categorical) variable for the color groups, and one for the type of distribution plot ("Box plot", "Violin plot", or "Strip chart"). 114 | On the strip char you can hover and click on the points, just like in the [scatterplot](#scatterplot). 115 | 116 | 117 | # Table 118 | 119 | ## Control of table 120 | 121 | There are one dropdown and two buttons in the control of a table. 122 | 123 | The dropdown is a multi-selectable dropdown, which has all the columns of the dataset and additionally "Clusters" column, if clusters have already made. By default, there are 5 columns in a table. The user can select all the columns to include in the table from the dropdown. 124 | 125 | If the user wants to select multiple values to the dropdown at the same time, the user can use regular expression (regex). The user needs to type a regex string to the dropdown and click "Add columns by regex" button. 126 | 127 | After adding all the wanted columns to the dropdown, the user can click "Select" button to recreate the table with the selected columns. 128 | 129 | ![table_columns_selec](../images/table_columns_selection.png) 130 | 131 | 132 | ## Selecting rows 133 | 134 | As written [above](#click-on-points), all the selected rows get a check mark and they are sent to the top of the table. When a new row is selected, the corresponding point on scatterplots become highlighted. 135 | 136 | ![table_row_selec](../images/table_select_row.png) 137 | 138 | # SMILES plot 139 | 140 | SMILES plot is connected with scatterplots and tables. 141 | 142 | ## Control of SMILES plot 143 | 144 | There are two dropdowns and one input field in the control of a SMILES plot. 145 | 146 | The input gets a SMILES string and convert it to a stick structure. If the input is invalid, the plot displays a red X mark. 147 | 148 | "SMILES column" dropdown has a list of all columns of the given dataset that have string items. The user can set the column that has SMILES strings in order to use the SMILES plot with scatterplots. 149 | 150 | "Selection mode" dropdown has two options: "hover" and "click". 151 | 152 | - On "hover" mode, SMILES plot is displayed when the user hovers over the points on scatterplots or clicks a cell on the SMILES column of a table. 153 | 154 | - On "click" mode, SMILES plot is displayed when the user clicks a point on a scatterplot or clicks a cell on the SMILES column of a table. 155 | 156 | SMILES plots can be displayed from the input with any mode. 157 | 158 | ![smiles_input](../images/smiles_input.png) 159 | 160 | 161 | -------------------------------------------------------------------------------- /docs/user_guide/plugin.md: -------------------------------------------------------------------------------- 1 | # Plugin documentation 2 | 3 | χiplot supports plugins for extending the functionality. 4 | Plugins works in both the local server version and the WASM version. 5 | 6 | ## Install plugins for the local server version 7 | 8 | 1. Install the plugins in the same Python environment as χiplot (e.g. using `pip install ...`). 9 | 2. Restart the χiplot server (if it is running). 10 | 11 | ## Install plugins for the WASM version 12 | 13 | 1. Go to the "Plugins" tab. 14 | 2. Write the name of the plugin, or select one from the list. 15 | 3. Click "Install" and wait for the installation to complete. 16 | 4. Click the "Reload χiplot" button to load all new plugins. 17 | 18 | 19 | # Create plugins 20 | 21 | Check out the [`test_plugin`](../../test_plugin/) or [filetypes plugin](../../plugin_xiplot_filetypes) for getting a better grasp of creating your own plugin packages. 22 | Below are instructions for creating different types of plugins. 23 | 24 | ## Read unsupported file extension 25 | 26 | A plugin package for reading data file with unsupported extensions. ([example](../../test_plugin/xiplot_test_plugin/__init__.py#L5-L10)) 27 | 28 | ### API requirements 29 | 30 | The plugin API requires a function returning two items. The first item must be a function that returns a pandas dataframe. The second item must be the new file extension as a string. 31 | 32 | ### Registeration to χiplot 33 | 34 | There are few steps to register a plugin package for reading unsupported data file extensions. 35 | 36 | 1. Create a pyproject.toml file into your package and include the following code 37 | 38 | ``` 39 | [project] 40 | name = "__plugin_package_name__" 41 | version = "xxx" 42 | 43 | dependencies = [xxx] 44 | 45 | [build-system] 46 | requires = ["setuptools>=42", "wheel"] 47 | build-backend = "setuptools.build_meta" 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["."] 51 | include = ["__plugin_package_name__"] 52 | exclude = [] 53 | namespaces = true 54 | 55 | [project.entry-points."xiplot.plugin.read"] 56 | __entry_point_name__ = "__plugin_package_name__:__plugin_read_function__" 57 | 58 | ``` 59 | 60 | Replace 61 | 62 | - `xxx` depending on your needs 63 | - `__plugin_package_name__` with your own package name 64 | - `__entry_point_name__` with an arbitrary entry point name 65 | - `__plugin_read_function__` with your read function of your package 66 | 67 | 2. Run your pyproject.toml with `pip install __plugin_package_name__` or if you have your package in the χiplot package, run `pip install __plugin_package_directory_name__/`. 68 | 69 | 3. Run χiplot normally with `python3 -m xiplot` and you are able to render your data file with the new file extension by uploading the file or by putting the file to `data` directory. 70 | 71 | 72 | ## Write unsupported data file extension 73 | 74 | A plugin package for writing and downloading unsupported file extensions. ([example](../../test_plugin/xiplot_test_plugin/__init__.py#L13-L17)) 75 | 76 | ### API requirements 77 | 78 | The plugin API requires a function returning three items. 79 | 80 | - The first item must be a function that writes the dataframe to bytes. The function must have two parameters: pandas dataframe and a file name as a string. 81 | 82 | - The second item must be the the new file extension as a string that matches the written data. 83 | 84 | - The third item must be the MIME type of the data as a string. 85 | 86 | ### Registeration to χiplot 87 | 88 | The registeration steps are similar to the registeration of the previous plugin package. 89 | 90 | 1. Create a pyproject.toml file into your package and include the following code 91 | 92 | ``` 93 | [project] 94 | name = "__plugin_package_name__" 95 | version = "xxx" 96 | 97 | dependencies = [xxx] 98 | 99 | [build-system] 100 | requires = ["setuptools>=42", "wheel"] 101 | build-backend = "setuptools.build_meta" 102 | 103 | [tool.setuptools.packages.find] 104 | where = ["."] 105 | include = ["__plugin_package_name__"] 106 | exclude = [] 107 | namespaces = true 108 | 109 | [project.entry-points."xiplot.plugin.write"] 110 | __entry_point_name__ = "__plugin_package_name__:__plugin_write_function__" 111 | 112 | ``` 113 | 114 | Replace 115 | 116 | - `xxx` depending on your needs 117 | - `__plugin_package_name__` with your own package name 118 | - `__entry_point_name__` with an arbitrary entry point name 119 | - `__plugin_write_function__` with your write function of your package 120 | 121 | 2. Run your pyproject.toml with `pip install __plugin_package_name__` or if you have your package in the χiplot package, run `pip install __plugin_package_directory_name__/`. 122 | 123 | 3. Run χiplot normally with `python3 -m xiplot` and you are able to download the loaded data into the data file with your new extension. 124 | 125 | 126 | ## New plot type 127 | 128 | A plugin package for rendering a new plot type. ([example](../../test_plugin/xiplot_test_plugin/__init__.py#L45-L72)) 129 | 130 | ### API requirements 131 | 132 | The plugin API requires a class with a classmethod `name` and two static methods `register_callbacks` and `create_new_layout`. 133 | 134 | - `name` method takes a class as a parameter and returns the name of it. 135 | 136 | - `register_callbacks` method requires three parameters: `app`, `df_from_store` and `df_to_store`. 137 | 138 | - `app` is an instance of the `dash.Dash` class, which is the main object that runs the application. 139 | 140 | - `df_from_store` is a function that transforms `dcc.Store` data into a pandas dataframe. 141 | 142 | - `df_to_store` is a function that transforms a dataframe to `dcc.Store` data. 143 | 144 | The purpose of the methods `df_from_store` and `df_to_store` are to reduce the time cost that occurs in a `Dash` app when every time a plot is been updated, the dataframe is been transferred from the server to the browser. 145 | 146 | - Add @app.callback decorators from `dash.Dash` instance `app` inside the `register_callbacks` method 147 | 148 | - `register_callback` does not require to return anything 149 | 150 | - `create_new_layout` method requires four parameters: `index`, `df`, `columns` and `config`. 151 | 152 | - `index` is the index of the plot. 153 | 154 | - `df` is a pandas dataframe. 155 | 156 | - `columns` is a list of columns from the dataframe to use in the plot. 157 | 158 | - `config` is the configuration dictionary of the plot. This is used when the user wants to save the rendered plots. Defaults to dict(). 159 | 160 | - `create_new_layout` requires to return a Dash HTML Components module (`dash.html`) 161 | 162 | 163 | ### Registeration to χiplot 164 | 165 | 166 | 1. Create a pyproject.toml file into your package and include the following code 167 | 168 | ``` 169 | [project] 170 | name = "__plugin_package_name__" 171 | version = "xxx" 172 | 173 | dependencies = [xxx] 174 | 175 | [build-system] 176 | requires = ["setuptools>=42", "wheel"] 177 | build-backend = "setuptools.build_meta" 178 | 179 | [tool.setuptools.packages.find] 180 | where = ["."] 181 | include = ["__plugin_package_name__"] 182 | exclude = [] 183 | namespaces = true 184 | 185 | [project.entry-points."xiplot.plugin.plot"] 186 | __entry_point_name__ = "__plugin_package_name__:__plugin_plot_class__" 187 | 188 | ``` 189 | 190 | Replace 191 | 192 | - `xxx` depending on your needs 193 | - `__plugin_package_name__` with your own package name 194 | - `__entry_point_name__` with an arbitrary entry point name 195 | - `__plugin_plot_class__` with your plot class name of your package 196 | 197 | 2. Run your pyproject.toml with `pip install __plugin_package_name__` or if you have your package in the χiplot package, run `pip install __plugin_package_directory_name__/`. 198 | 199 | 3. Run χiplot normally with `python3 -m xiplot` and you are able to download the loaded data into the data file with your new extension. 200 | 201 | 202 | ## Add a html component to the global layout 203 | 204 | A plugin package for adding new html component to the global layout on χiplot. ([example](../../test_plugin/xiplot_test_plugin/__init__.py#L20-L31)) 205 | 206 | ### API requirements 207 | 208 | The plugin API requires a function returning a Dash HTML Components module (`dash.html`). 209 | 210 | ### Registeration to χiplot 211 | 212 | The registeration steps are similar to the registeration of the previous plugin package. 213 | 214 | 1. Create a pyproject.toml file into your package and include the following code 215 | 216 | ``` 217 | [project] 218 | name = "__plugin_package_name__" 219 | version = "xxx" 220 | 221 | dependencies = [xxx] 222 | 223 | [build-system] 224 | requires = ["setuptools>=42", "wheel"] 225 | build-backend = "setuptools.build_meta" 226 | 227 | [tool.setuptools.packages.find] 228 | where = ["."] 229 | include = ["__plugin_package_name__"] 230 | exclude = [] 231 | namespaces = true 232 | 233 | [project.entry-points."xiplot.plugin.global"] 234 | __entry_point_name__ = "__plugin_package_name__:__plugin_create_global_function__" 235 | 236 | ``` 237 | 238 | Replace 239 | 240 | - `xxx` depending on your needs 241 | - `__plugin_package_name__` with your own package name 242 | - `__entry_point_name__` with an arbitrary entry point name 243 | - `__plugin_create_global_function__` with your function of your package that returns Dash HTML Component module 244 | 245 | 2. Run your pyproject.toml with `pip install __plugin_package_name__` or if you have your package in the χiplot package, run `pip install __plugin_package_directory_name__/`. 246 | 247 | 3. Run χiplot normally with `python3 -m xiplot` and you are able to download the loaded data into the data file with your new extension. 248 | 249 | 250 | ## Add @app.callback decorators 251 | 252 | A plugin package for adding @app.callback decorators of `dash.Dash` instance. The main use case would be to add user interactive actions, which are not inside of plots' instances. ([example](../../test_plugin/xiplot_test_plugin/__init__.py#L34-L42)) 253 | 254 | ### API requirements 255 | 256 | The plugin API requires a function returning a Dash HTML Components module (`dash.html`). This function is the same as the [`register_callbacks`](#api-requirements-2) method of a plot class of the plugin package. 257 | 258 | ### Registeration to χiplot 259 | 260 | The registeration steps are similar to the registeration of the previous plugin package. 261 | 262 | 1. Create a pyproject.toml file into your package and include the following code 263 | 264 | ``` 265 | [project] 266 | name = "__plugin_package_name__" 267 | version = "xxx" 268 | 269 | dependencies = [xxx] 270 | 271 | [build-system] 272 | requires = ["setuptools>=42", "wheel"] 273 | build-backend = "setuptools.build_meta" 274 | 275 | [tool.setuptools.packages.find] 276 | where = ["."] 277 | include = ["__plugin_package_name__"] 278 | exclude = [] 279 | namespaces = true 280 | 281 | [project.entry-points."xiplot.plugin.callback"] 282 | __entry_point_name__ = "__plugin_package_name__:__plugin_register_callbacks_function__" 283 | 284 | ``` 285 | 286 | Replace 287 | 288 | - `xxx` depending on your needs 289 | - `__plugin_package_name__` with your own package name 290 | - `__entry_point_name__` with an arbitrary entry point name 291 | - `__plugin_register_callbacks_function__` with your function of your package that returns Dash HTML Component module 292 | 293 | 2. Run your pyproject.toml with `pip install __plugin_package_name__` or if you have your package in the χiplot package, run `pip install __plugin_package_directory_name__/`. 294 | 295 | 3. Run χiplot normally with `python3 -m xiplot` and you are able to download the loaded data into the data file with your new extension. 296 | -------------------------------------------------------------------------------- /plugin_xiplot_filetypes/README.md: -------------------------------------------------------------------------------- 1 | # [χiplot](https://github.com/edahelsinki/xiplot) plugin for additional file types 2 | 3 | This plugin adds support for additional file types (beside `csv` and `json`) to [χiplot](https://github.com/edahelsinki/xiplot). 4 | Currently, this plugin adds support for `feather` and `parquet`. 5 | Note that in the [WASM version](https://edahelsinki.fi/xiplot) only support for `parquet` is added. 6 | 7 | ## Installation 8 | 9 | In non-WASM [χiplot](https://github.com/edahelsinki/xiplot) this plugin should be automatically installed. 10 | Otherwise you can use `pip install xiplot_filetypes` in the same Python environment. 11 | 12 | In the [WASM version](https://edahelsinki.fi/xiplot) you can install the plugin by going to the "Plugin" tab and selecting `xiplot_filetypes`. 13 | -------------------------------------------------------------------------------- /plugin_xiplot_filetypes/pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "xiplot_filetypes" 4 | version = "1.0" 5 | authors = [{ name = "Anton Björklund", email = "anton.bjorklund@helsinki.fi" }] 6 | description = "Xiplot plugin for additional file types" 7 | license = { file = "../LICENCE-MIT" } 8 | readme = "README.md" 9 | 10 | requires-python = ">=3.7" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "pandas", 18 | "pyarrow >= 11.0.0; platform_system!='Emscripten'", 19 | "fastparquet; platform_system=='Emscripten'", 20 | ] 21 | 22 | [build-system] 23 | requires = ["setuptools>=42", "wheel"] 24 | build-backend = "setuptools.build_meta" 25 | 26 | [tool.setuptools] 27 | packages = ["xiplot_filetypes"] 28 | 29 | [project.urls] 30 | homepage = "https://github.com/edahelsinki/xiplot" 31 | repository = "https://github.com/edahelsinki/xiplot.git" 32 | 33 | [project.entry-points."xiplot.plugin.read"] 34 | parquet-read = "xiplot_filetypes:read_parquet" 35 | feather-read = "xiplot_filetypes:read_feather" 36 | 37 | [project.entry-points."xiplot.plugin.write"] 38 | parquet-write = "xiplot_filetypes:write_parquet" 39 | feather-write = "xiplot_filetypes:write_feather" 40 | -------------------------------------------------------------------------------- /plugin_xiplot_filetypes/xiplot_filetypes/__init__.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import pandas as pd 4 | 5 | 6 | def read_parquet(): 7 | return pd.read_parquet, ".parquet" 8 | 9 | 10 | def write_parquet(): 11 | return pd.DataFrame.to_parquet, ".parquet", "application/octet-stream" 12 | 13 | 14 | def read_feather(): 15 | try: 16 | df = pd.DataFrame() 17 | ft = BytesIO() 18 | df.reset_index().to_feather(ft) 19 | pd.read_feather(ft) 20 | except ImportError: 21 | return 22 | 23 | return pd.read_feather, ".feather" 24 | 25 | 26 | def write_feather(): 27 | try: 28 | pd.DataFrame().reset_index().to_feather(BytesIO()) 29 | except ImportError: 30 | return 31 | 32 | return pd.DataFrame.to_feather, ".feather", "application/octet-stream" 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "xiplot" 3 | version = "0.4.1" 4 | authors = [ 5 | { name = "Akihiro Tanaka", email = "akihiro.fin@gmail.com" }, 6 | { name = "Juniper Tyree", email = "juniper.tyree@helsinki.fi" }, 7 | { name = "Anton Björklund" }, 8 | { name = "Jarmo Mäkelä" }, 9 | ] 10 | description = "Interactive data visualization tool" 11 | license = { file = "LICENSE-MIT" } 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | keywords = ["Visalisation", "Virtual Laboratory", "Exploratory Data Analysis"] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 20 | "Topic :: Scientific/Engineering :: Visualization", 21 | ] 22 | dependencies = [ 23 | "dash == 2.6.2", 24 | "dash-extensions == 0.1.4", 25 | "dash-mantine-components == 0.10.2", 26 | "dash-uploader ~= 0.6.0; platform_system!='Emscripten'", 27 | "jsonschema ~= 4.6.0; platform_system!='Emscripten'", 28 | "kaleido ~= 0.2.1; platform_system!='Emscripten'", 29 | "pandas >= 1.4.0, < 2.0.0", 30 | "plotly >= 5.9.0", 31 | "scikit-learn >= 1.0; platform_system!='Emscripten'", 32 | "xiplot_filetypes == 1.0; platform_system!='Emscripten'", 33 | "Werkzeug < 3.0.0", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "pytest", 39 | "black", 40 | "dash[testing]", 41 | "isort", 42 | "pyproject-flake8", 43 | "webdriver-manager", 44 | "selenium", 45 | "IPython", 46 | ] 47 | 48 | [project.urls] 49 | homepage = "https://github.com/edahelsinki/xiplot" 50 | repository = "https://github.com/edahelsinki/xiplot.git" 51 | 52 | [project.scripts] 53 | xiplot = "xiplot:cli" 54 | 55 | [build-system] 56 | requires = ["setuptools>=42", "wheel"] 57 | build-backend = "setuptools.build_meta" 58 | 59 | [tool.setuptools.packages.find] 60 | include = ["xiplot", "xiplot.*"] 61 | 62 | [tool.black] 63 | target-version = ['py37'] 64 | line-length = 79 65 | preview = true 66 | 67 | [tool.isort] 68 | py_version = 37 69 | profile = "black" 70 | line_length = 79 71 | 72 | [tool.flake8] 73 | max-line-length = 88 74 | exclude = "build/*" 75 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | build 3 | dash[testing] 4 | isort 5 | pyproject-flake8 6 | webdriver-manager 7 | selenium 8 | ./test_plugin -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dash==2.6.2 2 | dash-extensions==0.1.4 3 | dash-mantine-components==0.10.2 4 | dash-uploader~=0.6.0 5 | jsonschema~=4.6.0 6 | pandas>=1.4.0,<2.0.0 7 | plotly>=5.9.0 8 | scikit-learn>=1.0 9 | kaleido~=0.2.1 10 | packaging<22 # Needed for dash-uploader==0.6.0 11 | Werkzeug<3.0.0 # Needed for flask 2.X.X 12 | ./plugin_xiplot_filetypes 13 | -------------------------------------------------------------------------------- /test_plugin/pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "xiplot_test_plugin" 4 | version = "0.1.1" 5 | 6 | dependencies = ["pandas"] 7 | 8 | [build-system] 9 | requires = ["setuptools>=42", "wheel"] 10 | build-backend = "setuptools.build_meta" 11 | 12 | [tool.setuptools.packages.find] 13 | where = ["."] 14 | include = ["xiplot_test_plugin"] 15 | exclude = [] 16 | namespaces = true 17 | 18 | [project.entry-points."xiplot.plugin.read"] 19 | test-plugin-read = "xiplot_test_plugin:plugin_load" 20 | 21 | [project.entry-points."xiplot.plugin.write"] 22 | test-plugin-write = "xiplot_test_plugin:plugin_write" 23 | 24 | [project.entry-points."xiplot.plugin.global"] 25 | test-plugin-global = "xiplot_test_plugin:create_global" 26 | 27 | [project.entry-points."xiplot.plugin.callback"] 28 | test-plugin-callback = "xiplot_test_plugin:register_callbacks" 29 | 30 | [project.entry-points."xiplot.plugin.plot"] 31 | test-plugin-plot = "xiplot_test_plugin:Plot" 32 | -------------------------------------------------------------------------------- /test_plugin/xiplot_test_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from dash import MATCH, Input, Output, html 3 | 4 | 5 | def plugin_load(): 6 | def read(data): 7 | print("Reading test data") 8 | return pd.DataFrame({"x": [1, 2, 3]}) 9 | 10 | return read, ".test" 11 | 12 | 13 | def plugin_write(): 14 | def write(df, file): 15 | print("Writing test data") 16 | 17 | return write, ".test", "example/none" 18 | 19 | 20 | def create_global(): 21 | return html.Div( 22 | [ 23 | html.Button( 24 | ["Test Plugin"], id={"type": "test_plugin_button", "index": 0} 25 | ), 26 | html.Span( 27 | ["No clicks"], id={"type": "test_plugin_counter", "index": 0} 28 | ), 29 | ], 30 | id="test-plugin-global", 31 | ) 32 | 33 | 34 | def register_callbacks(app, df_from_store, df_to_store): 35 | @app.callback( 36 | Output({"type": "test_plugin_counter", "index": MATCH}, "children"), 37 | Input({"type": "test_plugin_button", "index": MATCH}, "n_clicks"), 38 | ) 39 | def counter(num): 40 | return f"{num} clicks" 41 | 42 | print("The test-plugin has registered a callback") 43 | 44 | 45 | class Plot: 46 | @classmethod 47 | def name(cls) -> str: 48 | return " TEST PLUGIN" 49 | 50 | @staticmethod 51 | def register_callbacks(app, df_from_store, df_to_store): 52 | @app.callback( 53 | Output({"index": MATCH, "type": "test_counter"}, "children"), 54 | Input({"index": MATCH, "type": "test_button"}, "n_clicks"), 55 | ) 56 | def counter(num): 57 | return f"{num} clicks" 58 | 59 | print("The test-plugin plot has registered a callback") 60 | 61 | @staticmethod 62 | def create_new_layout(index, df, columns, config=dict()): 63 | return html.Div( 64 | [ 65 | html.Button( 66 | ["Test Plot"], id={"index": index, "type": "test_button"} 67 | ), 68 | html.Span( 69 | ["No clicks"], id={"index": index, "type": "test_counter"} 70 | ), 71 | ] 72 | ) 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from selenium.webdriver.chrome.options import Options 5 | from webdriver_manager.chrome import ChromeDriverManager 6 | 7 | 8 | def pytest_configure(config): 9 | # wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add # noqa: E501 10 | # echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list # noqa: E501 11 | # sudo apt-get update 12 | # sudo apt-get install libnss3-dev 13 | # sudo apt-get install google-chrome-stable 14 | 15 | webdriver = ChromeDriverManager().install() 16 | os.environ["PATH"] = ( 17 | str(Path(webdriver).parent) + os.pathsep + os.environ["PATH"] 18 | ) 19 | 20 | config.option.webdriver = "Chrome" 21 | config.option.headless = True 22 | 23 | 24 | def pytest_setup_options(): 25 | options = Options() 26 | options.add_argument("--disable-gpu") 27 | return options 28 | -------------------------------------------------------------------------------- /tests/test_barplot.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import dash 4 | import pandas as pd 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.common.keys import Keys 7 | 8 | from tests.util_test import render_plot, start_server 9 | from xiplot.plots.barplot import Barplot 10 | 11 | render = Barplot.register_callbacks( 12 | dash.Dash(__name__), lambda x: x, lambda x: x 13 | )[0] 14 | 15 | 16 | def test_teba001_render_barplot(dash_duo): 17 | driver = start_server(dash_duo) 18 | render_plot(dash_duo, driver, "Barplot") 19 | 20 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 21 | 22 | assert "barplot" in plot.get_attribute("outerHTML") 23 | assert dash_duo.get_logs() == [], "browser console should contain no error" 24 | 25 | driver.close() 26 | 27 | 28 | def test_teba002_change_axis_value(dash_duo): 29 | driver = start_server(dash_duo) 30 | render_plot(dash_duo, driver, "Barplot") 31 | 32 | driver.find_element(By.CLASS_NAME, "dd-double-left").click() 33 | 34 | x = driver.find_element( 35 | By.XPATH, 36 | ( 37 | "//div[@class='dd-double-left']" 38 | "/div[2]/div[1]/div[1]/div[1]/div[2]/input" 39 | ), 40 | ) 41 | 42 | x.send_keys("model-year") 43 | x.send_keys(Keys.RETURN) 44 | time.sleep(0.5) 45 | 46 | assert "model-year" in driver.find_element(By.CLASS_NAME, "xtitle").text 47 | assert dash_duo.get_logs() == [], "browser console should contain no error" 48 | 49 | driver.close() 50 | 51 | 52 | def test_teba003_set_cluster(dash_duo): 53 | driver = start_server(dash_duo) 54 | render_plot(dash_duo, driver, "Barplot") 55 | 56 | # Run clustering 57 | driver.find_element(By.XPATH, "//div[@id='control-tabs']/div[3]").click() 58 | feature_dd = driver.find_element(By.ID, "cluster_feature") 59 | feature_dd.find_element(By.TAG_NAME, "input").send_keys("PCA") 60 | time.sleep(0.1) 61 | # The headless driver uses some wierd window size so that the dropdown 62 | # obscures the button. This is why we have cannot just use `click` here: 63 | driver.execute_script( 64 | "arguments[0].click();", 65 | driver.find_element(By.ID, "add_by_keyword-button"), 66 | ) 67 | time.sleep(0.1) 68 | driver.find_element(By.ID, "cluster-button").click() 69 | time.sleep(0.5) 70 | 71 | # Use clusters 72 | inp = driver.find_element( 73 | By.CSS_SELECTOR, ".dd-single.cluster-comparison input" 74 | ) 75 | inp.send_keys("2") 76 | inp.send_keys(Keys.RETURN) 77 | time.sleep(0.5) 78 | try: 79 | cluster_val = driver.find_element( 80 | By.CSS_SELECTOR, ".dd-single.cluster-comparison" 81 | ).find_element(By.CSS_SELECTOR, ".Select-value") 82 | except Exception: 83 | # Sometimes the GitHub test is too slow to find ".Select-value" 84 | cluster_val = driver.find_element( 85 | By.CSS_SELECTOR, ".dd-single.cluster-comparison" 86 | ) 87 | assert "Cluster #2" in cluster_val.get_attribute("innerHTML") 88 | 89 | assert dash_duo.get_logs() == [], "browser console should contain no error" 90 | driver.close() 91 | 92 | 93 | def test_teba004_set_order(dash_duo): 94 | driver = start_server(dash_duo) 95 | render_plot(dash_duo, driver, "Barplot") 96 | 97 | comparison_order_dd = driver.find_element( 98 | By.XPATH, 99 | "//div[@class='plots']/div[5]/div[2]", 100 | ) 101 | comparison_order_dd.click() 102 | 103 | dropdown_input = driver.find_element( 104 | By.XPATH, 105 | ( 106 | "//div[@class='plots']/div[4]/div[2]/div[1]/div[1]/div[1]/" 107 | "div[@class='Select-input']/input" 108 | ), 109 | ) 110 | dropdown_input.send_keys("total", Keys.RETURN) 111 | time.sleep(0.5) 112 | 113 | assert "total" in driver.find_element( 114 | By.XPATH, 115 | "//div[@class='plots']/div[4]/div[2]/div[1]/div[1]", 116 | ).get_attribute("outerHTML") 117 | assert dash_duo.get_logs() == [], "browser console should contain no error" 118 | 119 | driver.close() 120 | 121 | 122 | def test_create_barplot(): 123 | df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) 124 | output = render("col1", "col2", ["all"], "reldiff", 1, df, pd.DataFrame()) 125 | fig = output[0] 126 | assert str(type(fig)) == "" 127 | -------------------------------------------------------------------------------- /tests/test_boxplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | from selenium.webdriver.common.by import By 4 | 5 | from tests.util_test import ( 6 | click_pdf_button, 7 | render_plot, 8 | select_dropdown, 9 | start_server, 10 | ) 11 | from xiplot.plots.boxplot import Boxplot 12 | 13 | 14 | def test_boxplot_browser(dash_duo): 15 | driver = start_server(dash_duo) 16 | render_plot(dash_duo, driver, "boxplot") 17 | 18 | # Check render 19 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 20 | assert "Boxplot" in plot.get_attribute("id") 21 | dropdowns = driver.find_element(By.ID, "plots").find_elements( 22 | By.CLASS_NAME, "dash-dropdown" 23 | ) 24 | assert len(dropdowns) == 8 25 | 26 | # Change axis 27 | select_dropdown(dropdowns[0], "origin") 28 | assert "origin" in driver.find_element(By.CLASS_NAME, "xtitle").text 29 | 30 | # Change color 31 | select_dropdown(dropdowns[4], "cylinder") 32 | assert "cylinder" in driver.find_element(By.CLASS_NAME, "dash-graph").text 33 | 34 | # Change plot 35 | select_dropdown(dropdowns[6], "Violin") 36 | select_dropdown(dropdowns[6], "Strip") 37 | 38 | # Download pdf 39 | click_pdf_button(driver) 40 | 41 | # Close browser 42 | assert dash_duo.get_logs() == [], "browser console should contain no error" 43 | driver.close() 44 | 45 | 46 | def test_boxplot_create(): 47 | render = Boxplot.register_callbacks( 48 | dash.Dash(__name__), lambda x: x, lambda x: x 49 | ) 50 | 51 | for plot in ["Box plot", "Violin plot", "Strip chart"]: 52 | fig = render( 53 | "col1", 54 | "col2", 55 | plot, 56 | "Clusters", 57 | 0, 58 | pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}), 59 | pd.DataFrame(index=range(2)), 60 | None, 61 | ) 62 | assert str(type(fig)) == "" 63 | -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from xiplot.utils.dataframe import read_only_dataframe, write_functions 7 | 8 | 9 | def test_read_write(): 10 | orig = pd.DataFrame( 11 | dict( 12 | x=np.arange(5), 13 | y=[f"a{i}" for i in range(5)], 14 | b=np.arange(5) > 2, 15 | c=np.arange(1, 6), 16 | ) 17 | ) 18 | 19 | def write(fn, df=orig): 20 | bytes = BytesIO() 21 | fn(orig, bytes) 22 | return BytesIO(bytes.getvalue()) 23 | 24 | for fn, ext, mime in write_functions(): 25 | bytes1 = write(fn) 26 | try: 27 | read1 = read_only_dataframe(bytes1, "a" + ext) 28 | except Exception: 29 | print("Error when reading:", ext) 30 | raise 31 | if "example" not in mime: 32 | assert orig.equals(read1), "Could not handle: " + ext 33 | bytes2 = write(fn, read1) 34 | read2 = read_only_dataframe(bytes2, "b" + ext) 35 | assert read1.equals(read2), "Is not consistent: " + ext 36 | -------------------------------------------------------------------------------- /tests/test_distplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | from selenium.webdriver.common.by import By 4 | 5 | from tests.util_test import ( 6 | click_pdf_button, 7 | render_plot, 8 | select_dropdown, 9 | start_server, 10 | ) 11 | from xiplot.plots.distplot import Distplot 12 | from xiplot.utils.auxiliary import get_selected 13 | 14 | ( 15 | render, 16 | handle_hover_events, 17 | handle_click_events, 18 | ) = Distplot.register_callbacks(dash.Dash(__name__), lambda x: x, lambda x: x) 19 | 20 | 21 | def test_distplot_browser(dash_duo): 22 | driver = start_server(dash_duo) 23 | render_plot(dash_duo, driver, Distplot.name()) 24 | 25 | # Check render 26 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 27 | assert "Distplot" in plot.get_attribute("id") 28 | dropdowns = driver.find_element(By.ID, "plots").find_elements( 29 | By.CLASS_NAME, "dash-dropdown" 30 | ) 31 | assert len(dropdowns) == 4 32 | 33 | # Change axis 34 | select_dropdown(dropdowns[0], "mpg") 35 | assert "mpg" in driver.find_element(By.CLASS_NAME, "xtitle").text 36 | 37 | # Change color 38 | select_dropdown(dropdowns[2], "cylinders") 39 | assert "cylinders" in driver.find_element(By.CLASS_NAME, "dash-graph").text 40 | 41 | # Download pdf 42 | click_pdf_button(driver) 43 | 44 | # Close browser 45 | assert dash_duo.get_logs() == [], "browser console should contain no error" 46 | driver.close() 47 | 48 | 49 | def test_distplot_create(): 50 | fig = render( 51 | "col1", 52 | "Clusters", 53 | 0, 54 | pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}), 55 | pd.DataFrame(index=range(2)), 56 | None, 57 | ) 58 | assert str(type(fig)) == "" 59 | 60 | 61 | def test_distplot_click(): 62 | click = [{"points": [{"customdata": 0}]}] 63 | aux, row, _ = handle_click_events(click, pd.DataFrame(index=range(2))) 64 | 65 | assert all(get_selected(aux) == [True, False]) 66 | assert row == 0 67 | 68 | 69 | def test_distplot_hover(): 70 | hover = [{"points": [{"customdata": 1}]}] 71 | assert handle_hover_events(hover)[0] == 1 72 | -------------------------------------------------------------------------------- /tests/test_filetypes.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import pandas as pd 4 | 5 | try: 6 | from xiplot_filetypes import ( 7 | read_feather, 8 | read_parquet, 9 | write_feather, 10 | write_parquet, 11 | ) 12 | except ImportError: 13 | import sys 14 | from pathlib import Path 15 | 16 | sys.path.insert( 17 | 0, str(Path(__file__).parent.parent / "plugin_xiplot_filetypes") 18 | ) 19 | from xiplot_filetypes import ( 20 | read_feather, 21 | read_parquet, 22 | write_feather, 23 | write_parquet, 24 | ) 25 | 26 | 27 | def test_feather(): 28 | df = pd.DataFrame({"a": [1, 2, 3], "b": ["a", "b", "c"]}) 29 | io = BytesIO() 30 | write_feather()[0](df, io) 31 | df2 = read_feather()[0](io) 32 | assert df.equals(df2) 33 | 34 | 35 | def test_parquet(): 36 | df = pd.DataFrame({"a": [1, 2, 3], "b": ["a", "b", "c"]}) 37 | io = BytesIO() 38 | write_parquet()[0](df, io) 39 | df2 = read_parquet()[0](io) 40 | assert df.equals(df2) 41 | -------------------------------------------------------------------------------- /tests/test_heatmap.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | from selenium.webdriver.common.by import By 4 | 5 | from tests.util_test import render_plot, start_server 6 | from xiplot.plots.heatmap import Heatmap 7 | 8 | render = Heatmap.register_callbacks( 9 | dash.Dash(__name__), lambda x: x, lambda x: x 10 | )[0] 11 | 12 | 13 | def test_tehe001_render_heatmap(dash_duo): 14 | driver = start_server(dash_duo) 15 | render_plot(dash_duo, driver, "Heatmap") 16 | 17 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 18 | 19 | assert "heatmap" in plot.get_attribute("outerHTML") 20 | assert dash_duo.get_logs() == [], "browser console should contain no error" 21 | 22 | driver.close() 23 | 24 | 25 | def test_create_heatmap(): 26 | d = {"col1": [1, 2], "col2": [3, 4]} 27 | df = pd.DataFrame(data=d) 28 | output = render(2, ["col1", "col2"], 1, df, pd.DataFrame()) 29 | fig = output 30 | 31 | assert str(type(fig)) == "" 32 | -------------------------------------------------------------------------------- /tests/test_histogram.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import dash 4 | import pandas as pd 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.common.keys import Keys 7 | 8 | from tests.util_test import render_plot, start_server 9 | from xiplot.plots.histogram import Histogram 10 | 11 | render = Histogram.register_callbacks( 12 | dash.Dash(__name__), lambda x: x, lambda x: x 13 | )[0] 14 | 15 | 16 | def test_tehi001_render_histogram(dash_duo): 17 | driver = start_server(dash_duo) 18 | render_plot(dash_duo, driver, "Histogram") 19 | 20 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 21 | 22 | assert "histogram" in plot.get_attribute("outerHTML") 23 | assert dash_duo.get_logs() == [], "browser console should contain no error" 24 | 25 | driver.close() 26 | 27 | 28 | def test_tehi002_set_axis(dash_duo): 29 | driver = start_server(dash_duo) 30 | render_plot(dash_duo, driver, "Histogram") 31 | 32 | driver.find_element(By.CLASS_NAME, "dd-single").click() 33 | 34 | x = driver.find_element( 35 | By.XPATH, 36 | "//div[@class='dd-single']/div[2]/div[1]/div[1]/div[1]/div[2]/input", 37 | ) 38 | 39 | x.send_keys("mpg") 40 | x.send_keys(Keys.RETURN) 41 | time.sleep(0.5) 42 | 43 | assert "mpg" in driver.find_element(By.CLASS_NAME, "xtitle").text 44 | assert dash_duo.get_logs() == [], "browser console should contain no error" 45 | 46 | driver.close() 47 | 48 | 49 | def test_tehi003_clear_clusters(dash_duo): 50 | driver = start_server(dash_duo) 51 | render_plot(dash_duo, driver, "Histogram") 52 | 53 | cluster_dd = driver.find_element( 54 | By.XPATH, 55 | "//div[@class='plots']/div[3]/div[2]/div[1]/div[1]/span[1]", 56 | ) 57 | cluster_dd.click() 58 | 59 | assert "Select..." in driver.find_element( 60 | By.XPATH, 61 | "//div[@class='plots']/div[4]/div[2]/div[1]/div[1]", 62 | ).get_attribute("outerHTML") 63 | assert dash_duo.get_logs() == [], "browser console should contain no error" 64 | 65 | driver.close() 66 | 67 | 68 | def test_create_histogram(): 69 | df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) 70 | output = render("col1", "all", 1, df, pd.DataFrame()) 71 | fig = output 72 | 73 | assert str(type(fig)) == "" 74 | -------------------------------------------------------------------------------- /tests/test_launch.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from xiplot.setup import setup_xiplot_dash_app 4 | 5 | 6 | def test_dash001_launch(dash_duo): 7 | dash_duo.start_server(setup_xiplot_dash_app(data_dir="data")) 8 | time.sleep(1) 9 | dash_duo.wait_for_page() 10 | 11 | assert dash_duo.get_logs() == [], "browser console should contain no error" 12 | -------------------------------------------------------------------------------- /tests/test_lazyload.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import sys 3 | 4 | 5 | def check_loaded(queue, modules): 6 | import xiplot # noqa: F401 7 | import xiplot.setup # noqa: F401 8 | 9 | queue.put([mod in sys.modules for mod in modules]) 10 | 11 | 12 | def test_ensure_lazyload(): 13 | # This tests that the following packages are not imported when xiplot is 14 | # imported, so that they can be lazily loaded in the WASM version. 15 | # This test starts a new Python (multi-)process using "spawn" to check the 16 | # imports in a clean environment. 17 | modules = ["sklearn", "jsonschema", "plotly.figure_factory"] 18 | ctx = multiprocessing.get_context("spawn") 19 | q = ctx.Queue() 20 | p = ctx.Process(target=check_loaded, args=(q, modules)) 21 | p.start() 22 | for mod, loaded in zip(modules, q.get()): 23 | assert not loaded, f"{mod} should be lazily loaded" 24 | p.join() 25 | -------------------------------------------------------------------------------- /tests/test_lineplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | from selenium.webdriver.common.by import By 4 | 5 | from tests.util_test import ( 6 | click_pdf_button, 7 | render_plot, 8 | select_dropdown, 9 | start_server, 10 | ) 11 | from xiplot.plots.lineplot import Lineplot 12 | from xiplot.utils.auxiliary import get_selected 13 | 14 | ( 15 | render, 16 | handle_hover_events, 17 | handle_click_events, 18 | ) = Lineplot.register_callbacks(dash.Dash(__name__), lambda x: x, lambda x: x) 19 | 20 | 21 | def test_lineplot_browser(dash_duo): 22 | driver = start_server(dash_duo) 23 | render_plot(dash_duo, driver, "Lineplot") 24 | 25 | # Check render 26 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 27 | assert "Lineplot" in plot.get_attribute("id") 28 | dropdowns = driver.find_element(By.ID, "plots").find_elements( 29 | By.CLASS_NAME, "dash-dropdown" 30 | ) 31 | assert len(dropdowns) == 6 32 | 33 | # Change axis 34 | select_dropdown(dropdowns[0], "mpg") 35 | assert "mpg" in driver.find_element(By.CLASS_NAME, "xtitle").text 36 | 37 | # Change color 38 | select_dropdown(dropdowns[4], "cylinders") 39 | assert "cylinders" in driver.find_element(By.CLASS_NAME, "dash-graph").text 40 | 41 | # Download pdf 42 | click_pdf_button(driver) 43 | 44 | # Close browser 45 | assert dash_duo.get_logs() == [], "browser console should contain no error" 46 | driver.close() 47 | 48 | 49 | def test_lineplot_create(): 50 | fig = render( 51 | "col1", 52 | "col2", 53 | "Clusters", 54 | 0, 55 | pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}), 56 | pd.DataFrame(index=range(2)), 57 | None, 58 | ) 59 | assert str(type(fig)) == "" 60 | 61 | 62 | def test_lineplot_click(): 63 | click = [{"points": [{"customdata": [0]}]}] 64 | aux, row, _ = handle_click_events(click, pd.DataFrame(index=range(2))) 65 | 66 | assert all(get_selected(aux) == [True, False]) 67 | assert row == 0 68 | 69 | 70 | def test_lineplot_hover(): 71 | hover = [{"points": [{"customdata": [1]}]}] 72 | assert handle_hover_events(hover)[0] == 1 73 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from xiplot.tabs.plugins import get_plugins_cached 4 | from xiplot.utils.dataframe import read_functions, write_functions 5 | 6 | 7 | def test_plugin(): 8 | try: 9 | import xiplot_test_plugin # noqa: F401 10 | except ModuleNotFoundError: 11 | warnings.warn( 12 | "The test plugin is not installed, skipping plugin tests" 13 | ) 14 | return 15 | 16 | # Remember to update this test if anything is added to the test_plugin. 17 | try: 18 | from xiplot_test_plugin import ( 19 | Plot, 20 | create_global, 21 | plugin_load, 22 | plugin_write, 23 | register_callbacks, 24 | ) 25 | except ImportError: 26 | warnings.warn( 27 | "The test plugin needs to be updated: " 28 | "`pip install --upgrade ./test_plugin`" 29 | ) 30 | raise 31 | 32 | # Writing 33 | assert any( 34 | plugin == plugin_load for (_, _, plugin) in get_plugins_cached("read") 35 | ) 36 | assert any(ext == plugin_load()[1] for _, ext in read_functions()) 37 | 38 | # Reading 39 | assert any( 40 | plugin == plugin_write 41 | for (_, _, plugin) in get_plugins_cached("write") 42 | ) 43 | assert any(ext == plugin_write()[1] for _, ext, _ in write_functions()) 44 | 45 | # Plotting 46 | assert any(plugin == Plot for (_, _, plugin) in get_plugins_cached("plot")) 47 | assert any( 48 | plot.name() == Plot.name() 49 | for (_, _, plot) in get_plugins_cached("plot") 50 | ) 51 | 52 | # Globals 53 | assert any( 54 | plugin == create_global 55 | for (_, _, plugin) in get_plugins_cached("global") 56 | ) 57 | text = create_global().children[1].id["type"] 58 | assert any( 59 | text in str(g().children) for (_, _, g) in get_plugins_cached("global") 60 | ) 61 | 62 | # Callbacks 63 | assert any( 64 | plugin == register_callbacks 65 | for (_, _, plugin) in get_plugins_cached("callback") 66 | ) 67 | assert any( 68 | cb.__module__ == "xiplot_test_plugin" 69 | for (_, _, cb) in get_plugins_cached("callback") 70 | ) 71 | -------------------------------------------------------------------------------- /tests/test_scatterplot.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import dash 4 | import pandas as pd 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.common.keys import Keys 7 | 8 | from tests.util_test import render_plot, start_server 9 | from xiplot.plots.scatterplot import Scatterplot 10 | from xiplot.utils.auxiliary import get_clusters, get_selected 11 | 12 | ( 13 | tmp, 14 | handle_click_events, 15 | handle_hover_events, 16 | handle_cluster_drawing, 17 | ) = Scatterplot.register_callbacks( 18 | dash.Dash(__name__), lambda x: x, lambda x: x 19 | ) 20 | 21 | 22 | def test_tesc001_render_scatterplot(dash_duo): 23 | driver = start_server(dash_duo) 24 | render_plot(dash_duo, driver, "Scatterplot") 25 | 26 | plot = driver.find_element(By.CLASS_NAME, "dash-graph") 27 | 28 | assert "scatterplot" in plot.get_attribute("outerHTML") 29 | assert dash_duo.get_logs() == [], "browser console should contain no error" 30 | 31 | driver.close() 32 | 33 | 34 | def test_tesc002_change_axis_value(dash_duo): 35 | driver = start_server(dash_duo) 36 | render_plot(dash_duo, driver, "Scatterplot") 37 | 38 | driver.find_element(By.CLASS_NAME, "dd-double-left").click() 39 | 40 | x = driver.find_element( 41 | By.XPATH, 42 | ( 43 | "//div[@class='dd-double-left']" 44 | "/div[2]/div[1]/div[1]/div[1]/div[2]/input" 45 | ), 46 | ) 47 | 48 | x.send_keys("mpg") 49 | x.send_keys(Keys.RETURN) 50 | time.sleep(0.5) 51 | 52 | assert "mpg" in driver.find_element(By.CLASS_NAME, "xtitle").text 53 | assert dash_duo.get_logs() == [], "browser console should contain no error" 54 | 55 | driver.close() 56 | 57 | 58 | def test_tesc003_target_setting(dash_duo): 59 | driver = start_server(dash_duo) 60 | render_plot(dash_duo, driver, "Scatterplot") 61 | 62 | color = driver.find_element( 63 | By.XPATH, 64 | ( 65 | "//div[@class='plots']/div[3]/div[2]/" 66 | "div[1]/div[1]/div[1]/div[2]/input" 67 | ), 68 | ) 69 | 70 | color.send_keys("PCA 1") 71 | color.send_keys(Keys.RETURN) 72 | time.sleep(0.5) 73 | 74 | assert dash_duo.get_logs() == [], "browser console should contain no error" 75 | 76 | driver.close() 77 | 78 | 79 | def test_tesc004_jitter_setting(dash_duo): 80 | driver = start_server(dash_duo) 81 | render_plot(dash_duo, driver, "Scatterplot") 82 | 83 | jitter_slider = driver.find_element(By.CLASS_NAME, "rc-slider-step") 84 | jitter_slider.click() 85 | time.sleep(0.5) 86 | 87 | jitter_value = driver.find_element(By.CLASS_NAME, "rc-slider-handle") 88 | 89 | assert "0.5" in jitter_value.get_attribute("outerHTML") 90 | assert dash_duo.get_logs() == [], "browser console should contain no error" 91 | driver.close() 92 | 93 | 94 | def test_create_scatterplot(): 95 | fig = tmp( 96 | "col1", 97 | "col2", 98 | "Clusters", 99 | None, 100 | 0, 101 | pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}), 102 | pd.DataFrame(index=range(2)), 103 | None, 104 | ) 105 | assert str(type(fig)) == "" 106 | 107 | 108 | def test_handle_click_events(): 109 | click = [{"points": [{"customdata": [{"index": 0}]}]}] 110 | output = handle_click_events(click, pd.DataFrame(index=range(2))) 111 | 112 | aux = output["aux"] 113 | clicked_row = output["click_store"] 114 | clicked_update = output["scatter"] 115 | 116 | assert all(get_selected(aux) == [True, False]) 117 | assert clicked_row == 0 118 | assert clicked_update == [None] 119 | 120 | 121 | def test_handle_hover_events(): 122 | hover = [{"points": [{"customdata": [{"index": 1}]}]}] 123 | output = handle_hover_events(hover) 124 | 125 | hovered_row = output["hover_store"] 126 | hover_update = output["scatter"] 127 | 128 | assert hovered_row == 1 129 | assert hover_update == [None] 130 | 131 | 132 | def test_handle_cluster_drawing(): 133 | selected_points = [{"points": [{"customdata": [{"index": 1}]}]}] 134 | aux = handle_cluster_drawing( 135 | selected_points, pd.DataFrame(index=range(2)), "c1", False 136 | ) 137 | assert all(get_clusters(aux) == ["c2", "c1"]) 138 | -------------------------------------------------------------------------------- /tests/test_smiles.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | import pytest 4 | from selenium.webdriver.common.by import By 5 | from selenium.webdriver.common.keys import Keys 6 | 7 | from tests.util_test import render_plot, start_server 8 | from xiplot.plots.smiles import Smiles 9 | 10 | update_smiles = Smiles.register_callbacks( 11 | dash.Dash(__name__), lambda x: x, lambda x: x 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def driver(dash_duo): 17 | driver = start_server(dash_duo) 18 | render_plot(dash_duo, driver, "Smiles") 19 | return driver 20 | 21 | 22 | def test_tesm001_render_smiles(dash_duo, driver): 23 | plot = driver.find_element(By.XPATH, "//div[@class='plots']") 24 | assert Smiles.get_id(None, "display")["type"] in plot.get_attribute( 25 | "innerHTML" 26 | ) 27 | assert dash_duo.get_logs() == [], "browser console should contain no error" 28 | 29 | 30 | def test_tesm002_input_smiles_string(driver): 31 | smiles_input = driver.find_element( 32 | By.XPATH, "//div[@class='dash-input']/div/input" 33 | ) 34 | smiles_input.clear() 35 | smiles_input.send_keys("O", Keys.RETURN) 36 | 37 | smiles_img = ( 38 | driver.find_element(By.CLASS_NAME, "plots") 39 | .find_element(By.TAG_NAME, "img") 40 | .get_attribute("outerHTML") 41 | ) 42 | svg = 'src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0naXNvLTg4NTktMSc/PiA8c3ZnIHZlcnNpb249JzEuMScgYmFzZVByb2ZpbGU9J2Z1bGwnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZycgeG1sbnM6cmRraXQ9J2h0dHA6Ly93d3cucmRraXQub3JnL3htbCcgeG1sbnM6eGxpbms9J2h0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsnIHhtbDpzcGFjZT0ncHJlc2VydmUnIHdpZHRoPScyNTBweCcgaGVpZ2h0PScyMDBweCcgdmlld0JveD0nMCAwIDI1MCAyMDAnPiA8IS0tIEVORCBPRiBIRUFERVIgLS0+IDxwYXRoIGNsYXNzPSdhdG9tLTAnIGQ9J00gOTIuMSA3OC45IEwgOTYuMCA3OC45IEwgOTYuMCA5MS4wIEwgMTEwLjUgOTEuMCBMIDExMC41IDc4LjkgTCAxMTQuMyA3OC45IEwgMTE0LjMgMTA3LjIgTCAxMTAuNSAxMDcuMiBMIDExMC41IDk0LjIgTCA5Ni4wIDk0LjIgTCA5Ni4wIDEwNy4yIEwgOTIuMSAxMDcuMiBMIDkyLjEgNzguOSAnIGZpbGw9JyNGRjAwMDAnLz4gPHBhdGggY2xhc3M9J2F0b20tMCcgZD0nTSAxMTcuNyAxMDYuMiBRIDExOC40IDEwNC41LCAxMjAuMSAxMDMuNSBRIDEyMS43IDEwMi41LCAxMjQuMCAxMDIuNSBRIDEyNi44IDEwMi41LCAxMjguNCAxMDQuMCBRIDEzMC4wIDEwNS42LCAxMzAuMCAxMDguMyBRIDEzMC4wIDExMS4xLCAxMjcuOSAxMTMuNiBRIDEyNS45IDExNi4yLCAxMjEuNyAxMTkuMyBMIDEzMC4zIDExOS4zIEwgMTMwLjMgMTIxLjQgTCAxMTcuNyAxMjEuNCBMIDExNy43IDExOS42IFEgMTIxLjIgMTE3LjEsIDEyMy4yIDExNS4zIFEgMTI1LjMgMTEzLjUsIDEyNi4zIDExMS44IFEgMTI3LjMgMTEwLjEsIDEyNy4zIDEwOC40IFEgMTI3LjMgMTA2LjYsIDEyNi40IDEwNS42IFEgMTI1LjUgMTA0LjYsIDEyNC4wIDEwNC42IFEgMTIyLjUgMTA0LjYsIDEyMS41IDEwNS4yIFEgMTIwLjUgMTA1LjgsIDExOS44IDEwNy4yIEwgMTE3LjcgMTA2LjIgJyBmaWxsPScjRkYwMDAwJy8+IDxwYXRoIGNsYXNzPSdhdG9tLTAnIGQ9J00gMTMxLjkgOTMuMCBRIDEzMS45IDg2LjIsIDEzNS4yIDgyLjQgUSAxMzguNiA3OC42LCAxNDQuOSA3OC42IFEgMTUxLjEgNzguNiwgMTU0LjUgODIuNCBRIDE1Ny45IDg2LjIsIDE1Ny45IDkzLjAgUSAxNTcuOSA5OS45LCAxNTQuNSAxMDMuOCBRIDE1MS4xIDEwNy43LCAxNDQuOSAxMDcuNyBRIDEzOC42IDEwNy43LCAxMzUuMiAxMDMuOCBRIDEzMS45IDk5LjksIDEzMS45IDkzLjAgTSAxNDQuOSAxMDQuNSBRIDE0OS4yIDEwNC41LCAxNTEuNSAxMDEuNiBRIDE1My45IDk4LjcsIDE1My45IDkzLjAgUSAxNTMuOSA4Ny40LCAxNTEuNSA4NC42IFEgMTQ5LjIgODEuOCwgMTQ0LjkgODEuOCBRIDE0MC41IDgxLjgsIDEzOC4yIDg0LjYgUSAxMzUuOSA4Ny40LCAxMzUuOSA5My4wIFEgMTM1LjkgOTguNywgMTM4LjIgMTAxLjYgUSAxNDAuNSAxMDQuNSwgMTQ0LjkgMTA0LjUgJyBmaWxsPScjRkYwMDAwJy8+IDwvc3ZnPiA="' # noqa: E501 43 | 44 | assert svg in smiles_img 45 | 46 | 47 | def test_render_clicks(): 48 | d = {"col1": [1, 2], "col2": [3, 4], "smiles": ["O", "N"]} 49 | df = pd.DataFrame(data=d) 50 | smiles_string = update_smiles(1, None, "Click", "smiles", "", df) 51 | assert smiles_string == "N" 52 | 53 | 54 | def test_render_hovered(): 55 | d = {"col1": [1, 2], "col2": [3, 4], "smiles": ["O", "N"]} 56 | df = pd.DataFrame(data=d) 57 | smiles_string = update_smiles(None, 1, "Hover", "smiles", "", df) 58 | assert smiles_string == "N" 59 | -------------------------------------------------------------------------------- /tests/test_table.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import dash 4 | import pandas as pd 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.common.keys import Keys 7 | 8 | from tests.util_test import render_plot, start_server 9 | from xiplot.plots.table import Table 10 | from xiplot.utils.auxiliary import SELECTED_COLUMN_NAME, get_selected 11 | 12 | ( 13 | update_table_data, 14 | update_selected_rows_store, 15 | update_lastly_activated_cell, 16 | update_table_columns, 17 | ) = Table.register_callbacks(dash.Dash(__name__), lambda x: x, lambda x: x) 18 | 19 | 20 | def test_teta001_render_table(dash_duo): 21 | driver = start_server(dash_duo) 22 | render_plot(dash_duo, driver, "Table") 23 | 24 | plot = driver.find_element( 25 | By.XPATH, 26 | "//table[@class='cell-table']", 27 | ) 28 | 29 | assert "PCA 1" in plot.get_attribute("outerHTML") 30 | assert dash_duo.get_logs() == [], "browser console should contain no error" 31 | 32 | driver.close() 33 | 34 | 35 | def test_teta002_select_columns(dash_duo): 36 | driver = start_server(dash_duo) 37 | render_plot(dash_duo, driver, "Table") 38 | 39 | column_dropdown = driver.find_element( 40 | By.XPATH, 41 | "//div[@class='plots']/div[2]/div", 42 | ) 43 | column_dropdown.click() 44 | 45 | column_dropdown_input = driver.find_element( 46 | By.XPATH, 47 | "//div[@class='plots']/div[2]/div/div[1]/div[1]/div[1]/div[2]/input", 48 | ) 49 | column_dropdown_input.send_keys("PCA 2", Keys.RETURN) 50 | time.sleep(0.5) 51 | 52 | column_dropdown_button = driver.find_element( 53 | By.XPATH, 54 | "//button[text()='Update table']", 55 | ) 56 | column_dropdown_button.click() 57 | time.sleep(0.5) 58 | 59 | plot = driver.find_element( 60 | By.XPATH, 61 | "//table[@class='cell-table']", 62 | ) 63 | 64 | assert "PCA 1" not in plot.get_attribute("outerHTML") 65 | assert "PCA 2" in plot.get_attribute("outerHTML") 66 | assert dash_duo.get_logs() == [], "browser console should contain no error" 67 | 68 | driver.close() 69 | 70 | 71 | def test_teta003_toggle_columns(dash_duo): 72 | driver = start_server(dash_duo) 73 | render_plot(dash_duo, driver, "Table") 74 | 75 | toggle_columns = driver.find_element( 76 | By.XPATH, 77 | "//button[text()='Toggle Columns']", 78 | ) 79 | toggle_columns.click() 80 | 81 | toggle_columns_first_checkbox = driver.find_element( 82 | By.XPATH, "//div[@class='show-hide-menu-item']/input" 83 | ) 84 | toggle_columns_first_checkbox.click() 85 | 86 | plot = driver.find_element( 87 | By.XPATH, 88 | "//table[@class='cell-table']", 89 | ) 90 | 91 | assert "PCA 1" not in plot.get_attribute("outerHTML") 92 | assert "PCA 2" in plot.get_attribute("outerHTML") 93 | assert dash_duo.get_logs() == [], "browser console should contain no error" 94 | 95 | driver.close() 96 | 97 | 98 | def test_create_table(): 99 | df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) 100 | aux = pd.DataFrame({SELECTED_COLUMN_NAME: [True, True]}) 101 | output = update_table_data(aux, [df], [[]]) 102 | table_df = output[0][0] 103 | sort_by = output[1][0] 104 | 105 | assert table_df[0] == {"col1": 1, "col2": 3, "Selection": True} 106 | assert table_df[1] == {"col1": 2, "col2": 4, "Selection": True} 107 | assert sort_by == [] 108 | 109 | 110 | def test_update_selected_rows_store(): 111 | aux = pd.DataFrame({SELECTED_COLUMN_NAME: [False, False]}) 112 | output = update_selected_rows_store([[1]], aux) 113 | assert all(get_selected(output) == [False, True]) 114 | 115 | 116 | def test_update_table_checkbox(): 117 | df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) 118 | aux = pd.DataFrame({SELECTED_COLUMN_NAME: [False, True]}) 119 | output = update_table_data(aux, [df], [[]]) 120 | selected_rows = output[2] 121 | 122 | assert selected_rows == [[1]] 123 | 124 | 125 | def test_update_lastly_activated_cell(): 126 | d = {"col1": [1, 2], "col2": [3, 4]} 127 | df = pd.DataFrame(data=d) 128 | output = update_lastly_activated_cell( 129 | [{"row": 1, "column_id": "col1"}], [[0, 1]], df 130 | ) 131 | lastly_activated_cell_row = output["cell_store"] 132 | updated_active_cell = output["active_cell"] 133 | 134 | assert lastly_activated_cell_row == 1 135 | assert updated_active_cell == [None] 136 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from xiplot.utils.auxiliary import decode_aux, encode_aux, toggle_selected 4 | from xiplot.utils.components import ColumnDropdown 5 | from xiplot.utils.regex import dropdown_regex, get_columns_by_regex 6 | 7 | 8 | def test_add_matching_values(): 9 | d = {"col1": [1, 2], "col2": [3.0, 4.0], "col3": ["a", "b"]} 10 | df = pd.DataFrame(data=d) 11 | assert ["col0", "col1", "col2", "col3"] == ColumnDropdown.get_columns( 12 | df, pd.DataFrame(), ["col0"] 13 | ) 14 | assert ["col1", "col2"] == ColumnDropdown.get_columns( 15 | df, pd.DataFrame(), numeric=True 16 | ) 17 | assert ["col1", "col3"] == ColumnDropdown.get_columns( 18 | df, pd.DataFrame(), category=True 19 | ) 20 | 21 | 22 | def test_regex(): 23 | options, selected, hits = dropdown_regex( 24 | ["asd", "bsd", "csd", "dsd"], ["asd", "bs (regex)"], "cs" 25 | ) 26 | assert options == ["asd", "bs (regex: 1)", "cs (regex: 1)", "dsd"] 27 | assert options[:-1] == selected 28 | assert hits == 1 29 | 30 | cols = get_columns_by_regex( 31 | ["asd", "bsd", "csd", "dsd"], ["asd", "(b|c)sd (regex)"] 32 | ) 33 | assert cols == ["asd", "bsd", "csd"] 34 | 35 | 36 | def test_aux(): 37 | df = pd.DataFrame(index=range(4)) 38 | assert df.equals(decode_aux(encode_aux(df))) 39 | df = pd.DataFrame( 40 | {"a": [1, 2, 3], "b": [1.0, 2.3, 3.2], "c": ["a", "b", "a"]} 41 | ) 42 | assert df.equals(decode_aux(encode_aux(df))) 43 | df = toggle_selected(df, []) 44 | assert df.equals(toggle_selected(toggle_selected(df, 1), [1])) 45 | -------------------------------------------------------------------------------- /tests/util_test.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from selenium.webdriver.common.by import By 4 | from selenium.webdriver.common.keys import Keys 5 | from selenium.webdriver.remote.webdriver import WebDriver 6 | 7 | from xiplot.setup import setup_xiplot_dash_app 8 | 9 | 10 | def start_server(dash_duo) -> WebDriver: 11 | dash_duo.start_server(setup_xiplot_dash_app(data_dir="data")) 12 | driver = dash_duo.driver 13 | driver.implicitly_wait(5) # wait and repoll all `driver.find_element()` 14 | time.sleep(1) 15 | dash_duo.wait_for_page() 16 | time.sleep(0.1) 17 | return driver 18 | 19 | 20 | def load_file(dash_duo, driver): 21 | time.sleep(0.1) 22 | data_files_dd = dash_duo.find_element("#data_files") 23 | data_files_dd.click() 24 | 25 | dd_input = driver.find_element(By.XPATH, "//input[@autocomplete='off']") 26 | 27 | dd_input.send_keys("autompg-df.csv") 28 | dd_input.send_keys(Keys.RETURN) 29 | time.sleep(0.5) 30 | 31 | load_button = dash_duo.find_element("#submit-button") 32 | load_button.click() 33 | 34 | 35 | def render_plot(dash_duo, driver, plot_name): 36 | load_file(dash_duo, driver) 37 | 38 | plots_tab = driver.find_element( 39 | By.XPATH, "//div[@id='control-tabs']/div[2]" 40 | ) 41 | plots_tab.click() 42 | 43 | plot_type_dd_input = driver.find_element( 44 | By.XPATH, 45 | ( 46 | "//div[@id='plot_type']/div[1]/div[1]/div[1]" 47 | "/div[@class='Select-input']/input[1]" 48 | ), 49 | ) 50 | plot_type_dd_input.send_keys(plot_name) 51 | plot_type_dd_input.send_keys(Keys.RETURN) 52 | time.sleep(0.5) 53 | 54 | plot_add = driver.find_element(By.ID, "new_plot-button") 55 | plot_add.click() 56 | 57 | 58 | def select_dropdown(dropdown, input): 59 | dropdown.click() 60 | color = dropdown.find_element( 61 | By.XPATH, "//div[2]/div[1]/div[1]/div[1]/div[2]/input" 62 | ) 63 | color.send_keys(input) 64 | color.send_keys(Keys.RETURN) 65 | time.sleep(0.5) 66 | 67 | 68 | def click_pdf_button(driver): 69 | pdf = driver.find_element(By.CLASS_NAME, "pdf_button") 70 | driver.execute_script("arguments[0].click();", pdf) 71 | time.sleep(0.1) 72 | -------------------------------------------------------------------------------- /xiplot/__init__.py: -------------------------------------------------------------------------------- 1 | from xiplot.utils.cli import cli # noqa: F401 2 | -------------------------------------------------------------------------------- /xiplot/__main__.py: -------------------------------------------------------------------------------- 1 | from xiplot.utils.cli import cli 2 | 3 | cli() 4 | -------------------------------------------------------------------------------- /xiplot/app.py: -------------------------------------------------------------------------------- 1 | import dash_mantine_components as dmc 2 | from dash import dcc, html 3 | 4 | from xiplot.tabs.cluster import Cluster 5 | from xiplot.tabs.data import Data 6 | from xiplot.tabs.embedding import Embedding 7 | from xiplot.tabs.plots import Plots 8 | from xiplot.tabs.plugins import ( 9 | Plugins, 10 | get_plugins_cached, 11 | is_dynamic_plugin_loading_supported, 12 | ) 13 | from xiplot.tabs.settings import Settings 14 | from xiplot.utils.components import ClusterDropdown 15 | 16 | 17 | class XiPlot: 18 | def __init__( 19 | self, app, df_from_store, df_to_store, data_dir="", plugin_dir="" 20 | ) -> None: 21 | self.app = app 22 | self.app.title = "χiplot" 23 | 24 | try: 25 | import dash_uploader as du 26 | 27 | du.configure_upload( 28 | app=self.app, folder="uploads", use_upload_id=False 29 | ) 30 | except (ImportError, AttributeError): 31 | pass 32 | 33 | TABS = [Data, Plots, Cluster, Embedding, Settings] 34 | 35 | if is_dynamic_plugin_loading_supported(): 36 | TABS.insert(-1, Plugins) 37 | 38 | self.app.layout = dmc.NotificationsProvider( 39 | html.Div( 40 | [ 41 | html.Div( 42 | [ 43 | app_links(), 44 | app_logo(), 45 | dcc.Tabs( 46 | [ 47 | dcc.Tab( 48 | [ 49 | ( 50 | t.create_layout( 51 | data_dir=data_dir 52 | ) 53 | if t == Data 54 | else ( 55 | t.create_layout( 56 | plugin_dir=plugin_dir 57 | ) 58 | if t == Plugins 59 | else t.create_layout() 60 | ) 61 | ) 62 | ], 63 | label=t.name(), 64 | value=( 65 | f"control-{t.name().lower()}-tab" 66 | ), 67 | ) 68 | for t in TABS 69 | ], 70 | id="control-tabs", 71 | value=f"control-{TABS[0].name().lower()}-tab", 72 | ), 73 | ], 74 | id="control", 75 | className="control", 76 | ), 77 | html.Div(id="plots"), 78 | dcc.Store(id="data_frame_store"), 79 | dcc.Store(id="auxiliary_store"), 80 | dcc.Store(id="metadata_store"), 81 | dcc.Store(id="lastly_clicked_point_store"), 82 | dcc.Store(id="lastly_hovered_point_store"), 83 | html.Div( 84 | [t.create_layout_globals() for t in TABS], 85 | id="globals", 86 | ), 87 | html.Div( 88 | [g() for (_, _, g) in get_plugins_cached("global")], 89 | id="plugin-globals", 90 | ), 91 | ], 92 | id="main", 93 | ), 94 | position="top-right", 95 | ) 96 | 97 | for tab in TABS: 98 | ( 99 | tab.register_callbacks( 100 | app, df_from_store, df_to_store, data_dir=data_dir 101 | ) 102 | if tab == Data 103 | else ( 104 | tab.register_callbacks( 105 | app, 106 | df_from_store, 107 | df_to_store, 108 | plugin_dir=plugin_dir, 109 | ) 110 | if tab == Plugins 111 | else tab.register_callbacks( 112 | app, df_from_store, df_to_store 113 | ) 114 | ) 115 | ) 116 | 117 | for _, _, cb in get_plugins_cached("callback"): 118 | cb(app, df_from_store, df_to_store) 119 | 120 | ClusterDropdown.register_callbacks(app) 121 | 122 | 123 | def app_logo(): 124 | return html.Div([html.H1("χiplot")], id="logo", className="logo") 125 | 126 | 127 | def app_links(): 128 | return html.Div( 129 | [ 130 | html.A( 131 | html.Img(src="assets/book.svg", alt="Documentation"), 132 | href="https://github.com/edahelsinki/xiplot/blob/main/docs/README.md", 133 | title="Documentation", 134 | target="_blank", 135 | ), 136 | html.A( 137 | html.Img(src="assets/github.svg", alt="GitHub"), 138 | href="https://github.com/edahelsinki/xiplot", 139 | title="GitHub", 140 | target="_blank", 141 | ), 142 | ], 143 | className="links", 144 | ) 145 | -------------------------------------------------------------------------------- /xiplot/assets/RDKit_minimal.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/xiplot/assets/RDKit_minimal.wasm -------------------------------------------------------------------------------- /xiplot/assets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/xiplot/assets/__init__.py -------------------------------------------------------------------------------- /xiplot/assets/book.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /xiplot/assets/dcc_style.css: -------------------------------------------------------------------------------- 1 | .dd-single { 2 | margin-left: 10%; 3 | width: 82%; 4 | } 5 | 6 | .dd-double-left, 7 | .dcc-input { 8 | margin-left: 10%; 9 | display: inline-block; 10 | width: 40%; 11 | } 12 | 13 | .dcc-input input { 14 | width: 90%; 15 | background-color: white !important; 16 | background-color: var(--bg-color) !important; 17 | color: black !important; 18 | color: var(--font-color) !important; 19 | } 20 | 21 | .dd-double-right { 22 | margin-left: 2%; 23 | display: inline-block; 24 | width: 40%; 25 | } 26 | 27 | .dd-smiles { 28 | width: 30%; 29 | display: inline-block; 30 | margin-left: 10%; 31 | } 32 | 33 | .slider-single { 34 | width: 88.5%; 35 | padding-left: 10%; 36 | } 37 | 38 | .color-rect { 39 | display: inline-block; 40 | width: 20px; 41 | height: 20px; 42 | } 43 | 44 | .dcc-upload { 45 | width: auto; 46 | border-width: 1px; 47 | border-style: dashed; 48 | border-radius: 8px; 49 | border-radius: var(--large-radius); 50 | margin: 10px; 51 | text-align: center; 52 | min-height: 1; 53 | padding: 2rem 1rem 2rem 1rem; 54 | } 55 | 56 | .dash-uploader { 57 | position: relative; 58 | display: inline-block; 59 | flex: 1; 60 | min-height: 1; 61 | align-self: stretch; 62 | min-width: 12rem; 63 | margin: 0px; 64 | margin-bottom: auto; 65 | margin-top: auto; 66 | } 67 | 68 | .dash-uploader-default { 69 | border-radius: 8px !important; 70 | border-radius: var(--large-radius) !important; 71 | } 72 | 73 | .dash-uploader>* { 74 | margin: 1rem; 75 | width: auto !important; 76 | } 77 | 78 | .Select-control, 79 | .Select-menu-outer, 80 | .Select-input:not([aria-disabled='true']), 81 | .Select-input input { 82 | background-color: white !important; 83 | background-color: var(--bg-color) !important; 84 | color: black; 85 | color: var(--font-color); 86 | } 87 | 88 | .is-disabled .Select-control, 89 | .is-disabled .Select-menu-outer { 90 | background-color: grey !important; 91 | background-color: var(--bg-disabled-color) !important; 92 | } 93 | 94 | .Select-value-label { 95 | color: black !important; 96 | color: var(--font-color) !important; 97 | } 98 | 99 | 100 | .is-disabled .Select-value-label { 101 | color: black !important; 102 | color: var(--font-disabled-color) !important; 103 | } 104 | 105 | .Select-placeholder { 106 | color: black !important; 107 | color: var(--placeholder-color) !important; 108 | } 109 | 110 | .tab.jsx-535771872, 111 | .tab.jsx-3201076643 { 112 | padding: 20px 25px 5px 25px; 113 | } 114 | 115 | .tab.jsx-535771872.tab, 116 | .tab.jsx-3201076643.tab, 117 | .tab.jsx-535771872:last-of-type, 118 | .tab.jsx-3201076643:last-of-type { 119 | background-color: transparent; 120 | border: none !important; 121 | border-bottom: 2px solid #45A29E !important; 122 | border-bottom: 2px solid var(--tab-underline) !important; 123 | } 124 | 125 | .tab.jsx-535771872.tab:hover, 126 | .tab.jsx-3201076643.tab:hover { 127 | border-bottom: 4px solid #C5C6C7 !important; 128 | } 129 | 130 | .tab.jsx-535771872.tab.tab--selected, 131 | .tab.jsx-3201076643.tab.tab--selected { 132 | color: black !important; 133 | color: var(--font-color) !important; 134 | border-bottom: 4px solid rgb(214, 214, 214) !important; 135 | border-bottom: 4px solid var(--selected-tab-underline) !important; 136 | } 137 | 138 | .tab.jsx-535771872.tab.tab--selected:hover, 139 | .tab.jsx-3201076643.tab.tab--selected:hover { 140 | background-color: transparent; 141 | } 142 | 143 | .tab-content { 144 | padding: 10px; 145 | } 146 | 147 | .plots .dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner table, 148 | .plots .dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner th, 149 | .plots .dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner td, 150 | .plots .dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner input:not([type="radio"]):not([type="checkbox"]) { 151 | color: black; 152 | color: var(--font-color); 153 | background-color: white; 154 | background-color: var(--bg-color); 155 | } 156 | 157 | 158 | [data-theme="dark"] .dash-spreadsheet { 159 | border: 1px solid gray; 160 | border-top: 0px; 161 | } 162 | 163 | .plots .dash-spreadsheet-menu .dash-spreadsheet-menu-item .show-hide-menu { 164 | color: black; 165 | color: var(--font-color); 166 | background-color: white; 167 | background-color: var(--bg-color); 168 | } 169 | 170 | .dash-table-container .previous-next-container button.previous-page, 171 | .dash-table-container .previous-next-container button.next-page, 172 | .dash-table-container .previous-next-container button.first-page, 173 | .dash-table-container .previous-next-container button.last-page { 174 | border: 1px solid rgb(209, 213, 219); 175 | } 176 | 177 | .rc-slider-mark-text { 178 | color: darkgray; 179 | color: var(--font-disabled-color) 180 | } 181 | 182 | .rc-slider-mark-text-active { 183 | color: black; 184 | color: var(--font-color) 185 | } 186 | 187 | .Select--single>.Select-control .Select-value, 188 | .Select-placeholder { 189 | overflow-x: scroll; 190 | } -------------------------------------------------------------------------------- /xiplot/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edahelsinki/xiplot/1887afdb59c239073573af0a801dd6ffc0bed8d6/xiplot/assets/favicon.ico -------------------------------------------------------------------------------- /xiplot/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /xiplot/assets/html_components.css: -------------------------------------------------------------------------------- 1 | .button, 2 | button { 3 | background-color: transparent; 4 | border: 1px solid rgb(209, 213, 219); 5 | box-sizing: border-box; 6 | color: white; 7 | color: var(--font-color); 8 | font-size: .875rem; 9 | line-height: 1.25rem; 10 | text-align: center; 11 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1); 12 | cursor: pointer; 13 | user-select: none; 14 | -webkit-user-select: none; 15 | touch-action: manipulation; 16 | border-radius: 5px; 17 | border-radius: var(--small-radius); 18 | } 19 | 20 | button { 21 | padding: 0.2rem 0.5rem; 22 | } 23 | 24 | .button { 25 | padding: 0.45rem 1rem; 26 | } 27 | 28 | .button:hover:enabled, 29 | button:hover:enabled { 30 | background-color: rgb(249, 250, 251); 31 | color: #1F2833; 32 | } 33 | 34 | .button:active:enabled, 35 | button:active:enabled { 36 | background-color: rgb(192, 192, 192); 37 | color: #1F2833; 38 | } 39 | 40 | .button:disabled, 41 | button:disabled { 42 | opacity: 0.5; 43 | cursor: auto; 44 | } 45 | 46 | .button:focus, 47 | button:focus { 48 | outline: 2px solid transparent; 49 | outline-offset: 2px; 50 | } 51 | 52 | .button:focus-visible, 53 | button:focus-visible { 54 | box-shadow: none; 55 | } 56 | 57 | button.delete, 58 | .button.delete { 59 | font-size: large; 60 | font-weight: 900; 61 | background-color: red; 62 | color: white; 63 | } 64 | 65 | button.delete:hover, 66 | .button.delete:hover { 67 | background-color: white; 68 | color: red; 69 | } 70 | 71 | button.delete:active, 72 | .button.delete:active { 73 | background-color: red; 74 | color: red; 75 | } 76 | 77 | .light-dark-toggle { 78 | background-color: transparent; 79 | color: var(--font-color); 80 | } 81 | 82 | .flex-row { 83 | display: flex; 84 | flex-direction: row; 85 | flex-wrap: wrap; 86 | align-items: end; 87 | gap: 10px; 88 | gap: var(--panel-padding); 89 | } 90 | 91 | .flex-row .dash-dropdown, 92 | .flex-row .dash-input { 93 | min-width: 12rem; 94 | flex: 1; 95 | } 96 | 97 | .stretch { 98 | flex: 1; 99 | } -------------------------------------------------------------------------------- /xiplot/assets/stylesheet.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-color: black; 3 | --font-disabled-color: #888; 4 | --placeholder-color: #aaa; 5 | --body-bg-color: white; 6 | --control-bg-color: #dffcde; 7 | --smiles-img-bg-color: white; 8 | --bg-color: white; 9 | --bg-disabled-color: #dfecde; 10 | --tab-underline: #adb5bd; 11 | --selected-tab-underline: #198754; 12 | --base-column-size: 30%; 13 | --panel-padding: 10px; 14 | --plot-height: 450px; 15 | --large-radius: 8px; 16 | --small-radius: 5px; 17 | } 18 | 19 | [data-theme="dark"] { 20 | --font-color: white; 21 | --font-disabled-color: #a0a0aa; 22 | --placeholder-color: #a0aaba; 23 | --body-bg-color: #121212; 24 | --control-bg-color: #212121; 25 | --smiles-img-bg-color: #e5ecf6; 26 | --bg-color: #212121; 27 | --bg-disabled-color: #161616; 28 | --tab-underline: #45A29E; 29 | --selected-tab-underline: #D6D6D6; 30 | } 31 | 32 | [plot-height="350"] { 33 | --plot-height: 350px; 34 | } 35 | 36 | [plot-height="450"] { 37 | --plot-height: 450px; 38 | } 39 | 40 | [plot-height="550"] { 41 | --plot-height: 550px; 42 | } 43 | 44 | [plot-height="650"] { 45 | --plot-height: 650px; 46 | } 47 | 48 | [data-cols="1"] { 49 | --base-column-size: 80%; 50 | } 51 | 52 | [data-cols="2"] { 53 | --base-column-size: 40%; 54 | } 55 | 56 | [data-cols="4"] { 57 | --base-column-size: 22%; 58 | } 59 | 60 | [data-cols="5"] { 61 | --base-column-size: 18%; 62 | } 63 | 64 | body { 65 | width: auto !important; 66 | color: black; 67 | color: var(--font-color); 68 | background-color: white; 69 | background-color: var(--body-bg-color); 70 | } 71 | 72 | .control { 73 | width: 100%; 74 | display: inline-block; 75 | background-color: #dffcde; 76 | background-color: var(--control-bg-color); 77 | height: auto; 78 | border-radius: 5px; 79 | border-radius: var(--small-radius); 80 | box-shadow: 0px 4px 5px 0px hsla(0, 0%, 0%, 0.14), 81 | 0px 1px 10px 0px hsla(0, 0%, 0%, 0.12), 82 | 0px 2px 4px -1px hsla(0, 0%, 0%, 0.2); 83 | } 84 | 85 | .logo { 86 | text-align: center; 87 | margin: 0; 88 | color: black; 89 | color: var(--font-color) 90 | } 91 | 92 | .logo>h1 { 93 | margin: 0.2rem; 94 | } 95 | 96 | .links { 97 | position: absolute; 98 | text-align: right; 99 | right: 10px; 100 | right: var(--panel-padding); 101 | } 102 | 103 | .links img { 104 | height: 2.0rem; 105 | margin: 5px; 106 | } 107 | 108 | .links img:hover { 109 | filter: invert(0.4) 110 | } 111 | 112 | [data-theme="dark"] .links img { 113 | filter: invert(1) 114 | } 115 | 116 | [data-theme="dark"] .links img:hover { 117 | filter: invert(0.7) 118 | } 119 | 120 | #plots { 121 | display: flex; 122 | flex-direction: row; 123 | align-items: stretch; 124 | justify-content: center; 125 | align-content: center; 126 | flex-wrap: wrap; 127 | gap: 10px; 128 | gap: var(--panel-padding); 129 | margin-top: 10px; 130 | margin-top: var(--panel-padding); 131 | } 132 | 133 | .plots { 134 | display: block; 135 | min-width: 20rem; 136 | flex: 1 1 var(--base-column-size); 137 | background-color: #dffcde !important; 138 | background-color: var(--control-bg-color) !important; 139 | border-radius: 8px; 140 | border-radius: var(--large-radius); 141 | box-shadow: 0px 4px 5px 0px hsla(0, 0%, 0%, 0.14), 142 | 0px 1px 10px 0px hsla(0, 0%, 0%, 0.12), 143 | 0px 2px 4px -1px hsla(0, 0%, 0%, 0.2); 144 | padding: 10px; 145 | padding: var(--panel-padding); 146 | } 147 | 148 | .smiles-img { 149 | width: 100%; 150 | max-height: var(--plot-height); 151 | display: block; 152 | margin-left: auto; 153 | margin-right: auto; 154 | background-color: var(--smiles-img-bg-color); 155 | border-radius: 8px; 156 | border-radius: var(--large-radius); 157 | } 158 | 159 | .plot-container.plotly>div { 160 | height: var(--plot-height) !important; 161 | } -------------------------------------------------------------------------------- /xiplot/plots/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractclassmethod 2 | from typing import Any, Callable, Dict, List, Optional 3 | 4 | import pandas as pd 5 | from dash import Dash, dcc, html 6 | 7 | from xiplot.utils import generate_id 8 | from xiplot.utils.components import ( 9 | DeleteButton, 10 | FlexRow, 11 | HelpButton, 12 | PdfButton, 13 | PlotData, 14 | ) 15 | 16 | 17 | class APlot(ABC): 18 | """Abstract class that defines the API for implementing a new plot""" 19 | 20 | def __init__(self): 21 | raise TypeError("APlot should not be constructed") 22 | 23 | @classmethod 24 | def name(cls) -> str: 25 | """The name that is shown in the UI when selecting plots.""" 26 | return cls.__name__ 27 | 28 | @classmethod 29 | def help(cls) -> Optional[str]: 30 | """Tooltip that describes the plot and how to use it.""" 31 | # Recommended format: "Short description.\n\nLong description." 32 | return None 33 | 34 | @classmethod 35 | def get_id( 36 | cls, index: Any, subtype: Optional[str] = None 37 | ) -> Dict[str, Any]: 38 | """Generate id:s for the plot. 39 | 40 | Args: 41 | index: Index of the plot. 42 | subtype: Differentiatie parts of the plot. Defaults to None. 43 | 44 | Returns: 45 | Dash id. 46 | """ 47 | return generate_id(cls, index, subtype) 48 | 49 | @abstractclassmethod 50 | def register_callbacks( 51 | cls, 52 | app: Dash, 53 | df_from_store: Callable[[Any], pd.DataFrame], 54 | df_to_store: Callable[[pd.DataFrame], Any], 55 | ): 56 | """Override this to register Dash callbacks for your plot. 57 | 58 | If a PdfButton is added to the layout, remember to do: 59 | `PdfButton.register_callback(app, cls.name() cls.get_id(None))` 60 | If a PlotData is added to the layout, remember to do: 61 | `PlotData.register_callback(app, cls.name(), ...)` 62 | 63 | Args: 64 | app: The xiplot app (a subclass of `dash.Dash`). 65 | df_from_store: Functions that transforms `dcc.Store` data into a 66 | dataframe. 67 | df_to_store: Function that transform a dataframe into `dcc.Store` 68 | data. 69 | """ 70 | pass 71 | 72 | @abstractclassmethod 73 | def create_new_layout( 74 | cls, 75 | index: Any, 76 | df: pd.DataFrame, 77 | columns: Optional[List[str]] = None, 78 | config: Dict[str, Any] = dict(), 79 | ) -> html.Div: 80 | """Overide this method to create a layout for your plot. 81 | Alternatively you can overload `APlot.create_layout` for a simplified 82 | setup. 83 | 84 | Args: 85 | index: The index of the plot. 86 | df: Dataframe. 87 | columns: Columns from the dataframe to use in the plot. 88 | config: Plot configuration (used when restoring saved plots). 89 | Defaults to dict(). 90 | 91 | Returns: 92 | A html element presenting the plot. 93 | """ 94 | children = cls.create_layout(index, df, columns, config) 95 | top_bar = [DeleteButton(index), html.Div(className="stretch")] 96 | if any(isinstance(e, dcc.Graph) for e in children): 97 | top_bar.append(PdfButton(index, cls.name())) 98 | if cls.help(): 99 | top_bar.append(HelpButton(cls.help())) 100 | children.insert(0, FlexRow(*top_bar)) 101 | children.append(PlotData(index, cls.name())) 102 | return html.Div( 103 | children, id=cls.get_id(index, "panel"), className="plots" 104 | ) 105 | 106 | @abstractclassmethod 107 | def create_layout( 108 | cls, 109 | index: Any, 110 | df: pd.DataFrame, 111 | columns: Any, 112 | config: Dict[str, Any] = dict(), 113 | ) -> List[Any]: 114 | """If `APlot.create_new_layout` is not overriden, then this method can 115 | be overriden to provide a "standard" plot. 116 | 117 | A "standard" plot has the following features: 118 | - The children (given by this function) are wrapped in a Div with 119 | `className="plots"`. 120 | - A `DeleteButton` is added, so that the plot can be removed. 121 | - A `PdfButton` is added if any of the children is a `dcc.Graph`. 122 | - A `PlotData` is added, so that the plot can be saved. 123 | 124 | Args: 125 | index: The index of the plot 126 | df: Dataframe. 127 | columns: Columns from the dataframe to use in the plot. 128 | config: Plot configuration (used when restoring saved plots). 129 | Defaults to dict(). 130 | 131 | Returns: 132 | A list of (child) html components. 133 | """ 134 | return ["Not implemented"] 135 | -------------------------------------------------------------------------------- /xiplot/plots/boxplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import plotly.express as px 3 | from dash import ALL, MATCH, Input, Output, State, dcc 4 | from dash.exceptions import PreventUpdate 5 | 6 | from xiplot.plots import APlot 7 | from xiplot.plugin import ( 8 | ID_AUXILIARY, 9 | ID_CLICKED, 10 | ID_HOVERED, 11 | placeholder_figure, 12 | ) 13 | from xiplot.utils.auxiliary import ( 14 | CLUSTER_COLUMN_NAME, 15 | SELECTED_COLUMN_NAME, 16 | decode_aux, 17 | merge_df_aux, 18 | toggle_selected, 19 | ) 20 | from xiplot.utils.cluster import cluster_colours 21 | from xiplot.utils.components import ( 22 | ColumnDropdown, 23 | FlexRow, 24 | PdfButton, 25 | PlotData, 26 | ) 27 | from xiplot.utils.dataframe import get_default_column 28 | from xiplot.utils.layouts import layout_wrapper 29 | 30 | 31 | class Boxplot(APlot): 32 | @classmethod 33 | def register_callbacks(cls, app, df_from_store, df_to_store): 34 | PdfButton.register_callback(app, cls.name(), cls.get_id(MATCH)) 35 | 36 | @app.callback( 37 | Output(cls.get_id(MATCH), "figure"), 38 | Input(cls.get_id(MATCH, "x_axis"), "value"), 39 | Input(cls.get_id(MATCH, "y_axis"), "value"), 40 | Input(cls.get_id(MATCH, "plot"), "value"), 41 | Input(cls.get_id(MATCH, "color"), "value"), 42 | Input(ID_HOVERED, "data"), 43 | Input("data_frame_store", "data"), 44 | Input("auxiliary_store", "data"), 45 | Input("plotly-template", "data"), 46 | prevent_initial_call=False, 47 | ) 48 | def render( 49 | x_axis, 50 | y_axis, 51 | plot, 52 | color, 53 | hover, 54 | df, 55 | aux, 56 | template=None, 57 | ): 58 | return cls.render( 59 | df_from_store(df), 60 | decode_aux(aux), 61 | x_axis, 62 | y_axis, 63 | plot, 64 | color, 65 | hover, 66 | template, 67 | ) 68 | 69 | def get_row(hover): 70 | try: 71 | for p in hover: 72 | if p is not None: 73 | return p["points"][0]["customdata"][0] 74 | except (KeyError, TypeError): 75 | raise PreventUpdate() 76 | raise PreventUpdate() 77 | 78 | @app.callback( 79 | Output(ID_HOVERED, "data"), 80 | Output(cls.get_id(ALL), "hoverData"), 81 | Input(cls.get_id(ALL), "hoverData"), 82 | ) 83 | def handle_hover_events(hover): 84 | return get_row(hover), [None] * len(hover) 85 | 86 | @app.callback( 87 | Output(ID_AUXILIARY, "data"), 88 | Output(ID_CLICKED, "data"), 89 | Output(cls.get_id(ALL), "clickData"), 90 | Input(cls.get_id(ALL), "clickData"), 91 | State(ID_AUXILIARY, "data"), 92 | ) 93 | def handle_click_events(click, aux): 94 | row = get_row(click) 95 | if aux is None: 96 | return dash.no_update, row 97 | return toggle_selected(aux, row), row, [None] * len(click) 98 | 99 | PlotData.register_callback( 100 | cls.name(), 101 | app, 102 | { 103 | "x_axis": Input(cls.get_id(MATCH, "x_axis"), "value"), 104 | "y_axis": Input(cls.get_id(MATCH, "y_axis"), "value"), 105 | "color": Input(cls.get_id(MATCH, "color"), "value"), 106 | "plot": Input(cls.get_id(MATCH, "plot"), "value"), 107 | }, 108 | ) 109 | 110 | ColumnDropdown.register_callback( 111 | app, cls.get_id(ALL, "x_axis"), df_from_store, category=True 112 | ) 113 | ColumnDropdown.register_callback( 114 | app, cls.get_id(ALL, "y_axis"), df_from_store, numeric=True 115 | ) 116 | ColumnDropdown.register_callback( 117 | app, cls.get_id(ALL, "color"), df_from_store, category=True 118 | ) 119 | 120 | return render 121 | 122 | @staticmethod 123 | def render( 124 | df, 125 | aux, 126 | x_axis, 127 | y_axis, 128 | plot="Box plot", 129 | color=None, 130 | hover=None, 131 | template=None, 132 | ): 133 | if y_axis is None: 134 | return placeholder_figure("Please select y axis") 135 | df = merge_df_aux(df, aux) 136 | df["__Xiplot_index__"] = range(df.shape[0]) 137 | if x_axis not in df.columns: 138 | return placeholder_figure("Please select x axis") 139 | if color not in df.columns: 140 | color = None 141 | if plot == "Box plot": 142 | fig = px.box( 143 | df, 144 | x=x_axis, 145 | y=y_axis, 146 | color=color, 147 | notched=True, 148 | custom_data="__Xiplot_index__", 149 | color_discrete_map=cluster_colours(), 150 | template=template, 151 | ) 152 | fig.update_traces(dict(boxmean=True)) 153 | elif plot == "Violin plot": 154 | fig = px.violin( 155 | df, 156 | x=x_axis, 157 | y=y_axis, 158 | color=color, 159 | custom_data="__Xiplot_index__", 160 | color_discrete_map=cluster_colours(), 161 | template=template, 162 | ) 163 | elif plot == "Strip chart": 164 | fig = px.strip( 165 | df, 166 | x=x_axis, 167 | y=y_axis, 168 | color=color, 169 | color_discrete_map=cluster_colours(), 170 | custom_data="__Xiplot_index__", 171 | template=template, 172 | ) 173 | else: 174 | return placeholder_figure("Unsupported plot type") 175 | 176 | if hover is not None: 177 | fig.add_hline( 178 | df[y_axis][hover], 179 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 180 | layer="below", 181 | ) 182 | if SELECTED_COLUMN_NAME in aux: 183 | color = "#DDD" if template and "dark" in template else "#333" 184 | for x in df[y_axis][aux[SELECTED_COLUMN_NAME]]: 185 | fig.add_hline( 186 | x, line=dict(color=color, width=0.5), layer="below" 187 | ) 188 | return fig 189 | 190 | @classmethod 191 | def create_layout(cls, index, df, columns=None, config=dict()): 192 | num_columns = ColumnDropdown.get_columns(df, numeric=True) 193 | cat_columns = ColumnDropdown.get_columns(df, category=True) 194 | 195 | x_axis = config.get("x_axis", get_default_column(cat_columns, "x")) 196 | y_axis = config.get("y_axis", get_default_column(num_columns, "y")) 197 | color = config.get("color", CLUSTER_COLUMN_NAME) 198 | plot = config.get("plot", "Box plot") 199 | 200 | return [ 201 | dcc.Graph(id=cls.get_id(index)), 202 | FlexRow( 203 | layout_wrapper( 204 | component=ColumnDropdown( 205 | cls.get_id(index, "x_axis"), 206 | options=num_columns, 207 | value=x_axis, 208 | clearable=True, 209 | ), 210 | css_class="dash-dropdown", 211 | title="X", 212 | ), 213 | layout_wrapper( 214 | component=ColumnDropdown( 215 | cls.get_id(index, "y_axis"), 216 | options=num_columns, 217 | value=y_axis, 218 | clearable=False, 219 | ), 220 | css_class="dash-dropdown", 221 | title="Y", 222 | ), 223 | layout_wrapper( 224 | component=ColumnDropdown( 225 | cls.get_id(index, "color"), 226 | options=columns, 227 | value=color, 228 | clearable=True, 229 | ), 230 | css_class="dash-dropdown", 231 | title="Groups", 232 | ), 233 | layout_wrapper( 234 | component=dcc.Dropdown( 235 | id=cls.get_id(index, "plot"), 236 | options=["Box plot", "Violin plot", "Strip chart"], 237 | value=plot, 238 | clearable=False, 239 | ), 240 | css_class="dash-dropdown", 241 | title="Plot type", 242 | ), 243 | ), 244 | ] 245 | -------------------------------------------------------------------------------- /xiplot/plots/distplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import plotly.express as px 3 | from dash import ALL, MATCH, Input, Output, State, dcc 4 | from dash.exceptions import PreventUpdate 5 | 6 | from xiplot.plots import APlot 7 | from xiplot.plugin import ( 8 | ID_AUXILIARY, 9 | ID_CLICKED, 10 | ID_HOVERED, 11 | placeholder_figure, 12 | ) 13 | from xiplot.utils.auxiliary import ( 14 | CLUSTER_COLUMN_NAME, 15 | SELECTED_COLUMN_NAME, 16 | decode_aux, 17 | merge_df_aux, 18 | toggle_selected, 19 | ) 20 | from xiplot.utils.cluster import cluster_colours 21 | from xiplot.utils.components import ( 22 | ColumnDropdown, 23 | FlexRow, 24 | PdfButton, 25 | PlotData, 26 | ) 27 | from xiplot.utils.layouts import layout_wrapper 28 | 29 | 30 | class Distplot(APlot): 31 | @classmethod 32 | def name(cls): 33 | return "Density plot" 34 | 35 | @classmethod 36 | def register_callbacks(cls, app, df_from_store, df_to_store): 37 | PdfButton.register_callback(app, cls.name(), cls.get_id(MATCH)) 38 | 39 | @app.callback( 40 | Output(cls.get_id(MATCH), "figure"), 41 | Input(cls.get_id(MATCH, "variable"), "value"), 42 | Input(cls.get_id(MATCH, "color"), "value"), 43 | Input(ID_HOVERED, "data"), 44 | Input("data_frame_store", "data"), 45 | Input("auxiliary_store", "data"), 46 | Input("plotly-template", "data"), 47 | prevent_initial_call=False, 48 | ) 49 | def render( 50 | variable, 51 | color, 52 | hover, 53 | df, 54 | aux, 55 | template=None, 56 | ): 57 | return cls.render( 58 | df_from_store(df), 59 | decode_aux(aux), 60 | variable, 61 | color, 62 | hover, 63 | template, 64 | ) 65 | 66 | def get_row(hover): 67 | try: 68 | for p in hover: 69 | if p is not None: 70 | return p["points"][0]["customdata"] 71 | except (KeyError, TypeError): 72 | raise PreventUpdate() 73 | raise PreventUpdate() 74 | 75 | @app.callback( 76 | Output(ID_HOVERED, "data"), 77 | Output(cls.get_id(ALL), "hoverData"), 78 | Input(cls.get_id(ALL), "hoverData"), 79 | ) 80 | def handle_hover_events(hover): 81 | return get_row(hover), [None] * len(hover) 82 | 83 | @app.callback( 84 | Output(ID_AUXILIARY, "data"), 85 | Output(ID_CLICKED, "data"), 86 | Output(cls.get_id(ALL), "clickData"), 87 | Input(cls.get_id(ALL), "clickData"), 88 | State(ID_AUXILIARY, "data"), 89 | ) 90 | def handle_click_events(click, aux): 91 | row = get_row(click) 92 | if aux is None: 93 | return dash.no_update, row 94 | return toggle_selected(aux, row), row, [None] * len(click) 95 | 96 | PlotData.register_callback( 97 | cls.name(), 98 | app, 99 | { 100 | "variable": Input(cls.get_id(MATCH, "variable"), "value"), 101 | "color": Input(cls.get_id(MATCH, "color"), "value"), 102 | }, 103 | ) 104 | 105 | ColumnDropdown.register_callback( 106 | app, cls.get_id(ALL, "variable"), df_from_store, numeric=True 107 | ) 108 | ColumnDropdown.register_callback( 109 | app, cls.get_id(ALL, "color"), df_from_store, category=True 110 | ) 111 | 112 | return render, handle_hover_events, handle_click_events 113 | 114 | @staticmethod 115 | def render( 116 | df, 117 | aux, 118 | variable, 119 | color=None, 120 | hover=None, 121 | template=None, 122 | ): 123 | # `plotly.figure_factory` checks if scipy is installed and caches the 124 | # result. This causes issues if scipy is lazily loaded in WASM. 125 | import scipy # noqa: F401, isort: skip 126 | import plotly.figure_factory as ff 127 | 128 | df = merge_df_aux(df, aux) 129 | df["__Xiplot_index__"] = range(df.shape[0]) 130 | if variable not in df.columns: 131 | return placeholder_figure("Please select a variable") 132 | if color not in df.columns: 133 | fig = ff.create_distplot( 134 | [df[variable]], [variable], show_hist=False 135 | ) 136 | fig.update_layout( 137 | template=template, 138 | showlegend=False, 139 | xaxis_title=variable, 140 | yaxis_title="Density", 141 | yaxis_domain=[0.11, 1], 142 | yaxis2_domain=[0, 0.09], 143 | ) 144 | fig.data[1]["customdata"] = df["__Xiplot_index__"] 145 | 146 | else: 147 | df[color] = df[color].astype("category") 148 | df2 = df.groupby(color) 149 | cats = [ 150 | c 151 | for c in df[color].cat.categories 152 | if df2[variable].get_group(c).std() > 1e-6 153 | ] 154 | data = [df2[variable].get_group(g) for g in cats] 155 | customdata = [df2["__Xiplot_index__"].get_group(g) for g in cats] 156 | groups = [str(g) for g in cats] 157 | colors1 = px.colors.qualitative.Plotly 158 | colors2 = cluster_colours() 159 | colors = [] 160 | for i, n in enumerate(groups): 161 | colors.append(colors2.get(n, colors1[i % len(colors1)])) 162 | fig = ff.create_distplot( 163 | data, groups, show_hist=False, colors=colors 164 | ) 165 | fig.update_layout( 166 | template=template, 167 | legend=dict(title=color, traceorder="normal"), 168 | xaxis_title=variable, 169 | yaxis_title="Density", 170 | yaxis_domain=[0.16, 1] if len(cats) < 4 else [0.26, 1], 171 | yaxis2_domain=[0, 0.14] if len(cats) < 4 else [0, 0.24], 172 | ) 173 | for i, d in enumerate(customdata): 174 | fig.data[i + len(cats)]["customdata"] = d 175 | 176 | if hover is not None: 177 | fig.add_vline( 178 | df[variable][hover], 179 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 180 | layer="below", 181 | ) 182 | if SELECTED_COLUMN_NAME in aux: 183 | sel_color = "#DDD" if template and "dark" in template else "#333" 184 | for x in df[variable][aux[SELECTED_COLUMN_NAME]]: 185 | fig.add_vline( 186 | x, line=dict(color=sel_color, width=0.5), layer="below" 187 | ) 188 | x = df[variable][aux[SELECTED_COLUMN_NAME]] 189 | fig.add_scatter( 190 | x=x, 191 | y=( 192 | [variable] * len(x) 193 | if color not in df.columns 194 | else df[color][aux[SELECTED_COLUMN_NAME]] 195 | ), 196 | marker=dict(color=sel_color, size=8), 197 | yaxis="y2", 198 | hoverinfo="skip", 199 | hovertemplate=None, 200 | mode="markers", 201 | showlegend=False, 202 | ) 203 | return fig 204 | 205 | @classmethod 206 | def create_layout(cls, index, df, columns=None, config=dict()): 207 | num_columns = ColumnDropdown.get_columns(df, numeric=True) 208 | 209 | variable = config.get( 210 | "variable", num_columns[0] if num_columns else None 211 | ) 212 | color = config.get("color", CLUSTER_COLUMN_NAME) 213 | 214 | return [ 215 | dcc.Graph(id=cls.get_id(index)), 216 | FlexRow( 217 | layout_wrapper( 218 | component=ColumnDropdown( 219 | cls.get_id(index, "variable"), 220 | options=num_columns, 221 | value=variable, 222 | clearable=False, 223 | ), 224 | css_class="dash-dropdown", 225 | title="Variable", 226 | ), 227 | layout_wrapper( 228 | component=ColumnDropdown( 229 | cls.get_id(index, "color"), 230 | options=columns, 231 | value=color, 232 | clearable=True, 233 | ), 234 | css_class="dash-dropdown", 235 | title="Groups", 236 | ), 237 | ), 238 | ] 239 | -------------------------------------------------------------------------------- /xiplot/plots/heatmap.py: -------------------------------------------------------------------------------- 1 | import plotly.express as px 2 | from dash import MATCH, Input, Output, ctx, dcc, html 3 | from dash.exceptions import PreventUpdate 4 | 5 | from xiplot.plots import APlot 6 | from xiplot.plugin import ID_HOVERED 7 | from xiplot.utils.auxiliary import decode_aux, merge_df_aux 8 | from xiplot.utils.cluster import KMeans 9 | from xiplot.utils.components import ( 10 | ColumnDropdown, 11 | FlexRow, 12 | PdfButton, 13 | PlotData, 14 | ) 15 | from xiplot.utils.dataframe import get_numeric_columns 16 | from xiplot.utils.layouts import layout_wrapper 17 | from xiplot.utils.regex import get_columns_by_regex 18 | 19 | 20 | class Heatmap(APlot): 21 | @classmethod 22 | def register_callbacks(cls, app, df_from_store, df_to_store): 23 | PdfButton.register_callback(app, cls.name(), {"type": "heatmap"}) 24 | 25 | @app.callback( 26 | Output({"type": "heatmap", "index": MATCH}, "figure"), 27 | Input({"type": "heatmap_cluster_amount", "index": MATCH}, "value"), 28 | Input(cls.get_id(MATCH, "columns_dropdown"), "value"), 29 | Input(ID_HOVERED, "data"), 30 | Input("data_frame_store", "data"), 31 | Input("auxiliary_store", "data"), 32 | Input("plotly-template", "data"), 33 | prevent_initial_call=False, 34 | ) 35 | def tmp(n_clusters, features, hover, df, aux, template=None): 36 | # Try branch for testing 37 | try: 38 | if ctx.triggered_id == "data_frame_store": 39 | raise PreventUpdate() 40 | except PreventUpdate: 41 | raise 42 | except Exception: 43 | pass 44 | 45 | return Heatmap.render( 46 | n_clusters, 47 | features, 48 | hover, 49 | df_from_store(df), 50 | decode_aux(aux), 51 | template, 52 | ) 53 | 54 | ColumnDropdown.register_callback( 55 | app, 56 | cls.get_id(MATCH, "columns_dropdown"), 57 | df_from_store, 58 | numeric=True, 59 | regex_button_id=cls.get_id(MATCH, "regex_button"), 60 | regex_input_id=cls.get_id(MATCH, "regex_input"), 61 | ) 62 | 63 | PlotData.register_callback( 64 | cls.name(), 65 | app, 66 | dict( 67 | clusters=Input( 68 | {"type": "heatmap_cluster_amount", "index": MATCH}, "value" 69 | ), 70 | columns=Input(cls.get_id(MATCH, "columns_dropdown"), "value"), 71 | ), 72 | ) 73 | 74 | return [tmp] 75 | 76 | @staticmethod 77 | def render(n_clusters, features, hover, df, aux, template=None): 78 | km = KMeans(n_clusters=n_clusters, random_state=42) 79 | df = merge_df_aux(df, aux) 80 | 81 | features = get_columns_by_regex(df.columns.to_list(), features) 82 | features = features if features else df.columns.to_list() 83 | features = get_numeric_columns(df, features) 84 | dff = df[features].dropna() 85 | km.fit(dff) 86 | 87 | fig = px.imshow( 88 | km.cluster_centers_, 89 | x=features, 90 | y=list(range(1, n_clusters + 1)), 91 | color_continuous_scale="RdBu", 92 | origin="lower", 93 | aspect="auto", 94 | template=template, 95 | ) 96 | 97 | if hover is not None: 98 | # Due to dropna 99 | index = dff.index.searchsorted(hover) 100 | if index < len(dff.index) and dff.index[index] == hover: 101 | fig.add_hline( 102 | km.labels_[index] + 1, 103 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 104 | ) 105 | 106 | return fig 107 | 108 | @classmethod 109 | def create_layout(cls, index, df, columns, config=dict()): 110 | import jsonschema 111 | 112 | jsonschema.validate( 113 | instance=config, 114 | schema=dict( 115 | type="object", properties=dict(clusters=dict(type="integer")) 116 | ), 117 | ) 118 | n_clusters = config.get("clusters", 5) 119 | num_columns = get_numeric_columns(df, columns) 120 | return [ 121 | dcc.Graph(id={"type": "heatmap", "index": index}), 122 | FlexRow( 123 | layout_wrapper( 124 | component=ColumnDropdown( 125 | cls.get_id(index, "columns_dropdown"), 126 | options=num_columns, 127 | multi=True, 128 | clearable=False, 129 | value=config.get("columns", None), 130 | ), 131 | title="Features", 132 | css_class="dash-dropdown", 133 | ), 134 | html.Button( 135 | "Add features by regex", 136 | id=cls.get_id(index, "regex_button"), 137 | className="button", 138 | ), 139 | layout_wrapper( 140 | component=dcc.Slider( 141 | min=2, 142 | max=10, 143 | step=1, 144 | value=n_clusters, 145 | id={"type": "heatmap_cluster_amount", "index": index}, 146 | ), 147 | title="Number of clusters", 148 | css_class="dash-dropdown", 149 | ), 150 | ), 151 | dcc.Input( 152 | id=cls.get_id(index, "regex_input"), style={"display": "none"} 153 | ), 154 | ] 155 | -------------------------------------------------------------------------------- /xiplot/plots/histogram.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import plotly.express as px 3 | from dash import ALL, MATCH, Input, Output, ctx, dcc 4 | from dash.exceptions import PreventUpdate 5 | 6 | from xiplot.plots import APlot 7 | from xiplot.plugin import ID_HOVERED 8 | from xiplot.utils.auxiliary import ( 9 | SELECTED_COLUMN_NAME, 10 | decode_aux, 11 | get_clusters, 12 | merge_df_aux, 13 | ) 14 | from xiplot.utils.cluster import cluster_colours 15 | from xiplot.utils.components import ( 16 | ClusterDropdown, 17 | ColumnDropdown, 18 | PdfButton, 19 | PlotData, 20 | ) 21 | from xiplot.utils.dataframe import get_numeric_columns 22 | from xiplot.utils.layouts import layout_wrapper 23 | 24 | 25 | class Histogram(APlot): 26 | @classmethod 27 | def register_callbacks(cls, app, df_from_store, df_to_store): 28 | PdfButton.register_callback(app, cls.name(), {"type": "histogram"}) 29 | 30 | @app.callback( 31 | Output({"type": "histogram", "index": MATCH}, "figure"), 32 | Input(cls.get_id(MATCH, "x_axis_dropdown"), "value"), 33 | Input(ClusterDropdown.get_id(MATCH), "value"), 34 | Input(ID_HOVERED, "data"), 35 | Input("data_frame_store", "data"), 36 | Input("auxiliary_store", "data"), 37 | Input("plotly-template", "data"), 38 | prevent_initial_call=False, 39 | ) 40 | def tmp(x_axis, selected_clusters, hover, df, aux, template=None): 41 | # Try branch for testing 42 | try: 43 | if ctx.triggered_id == "data_frame_store": 44 | raise PreventUpdate() 45 | except PreventUpdate: 46 | raise 47 | except Exception: 48 | pass 49 | 50 | return Histogram.render( 51 | x_axis, 52 | selected_clusters, 53 | hover, 54 | df_from_store(df), 55 | decode_aux(aux), 56 | template, 57 | ) 58 | 59 | PlotData.register_callback( 60 | cls.name(), 61 | app, 62 | ( 63 | Input(cls.get_id(ALL, "x_axis_dropdown"), "value"), 64 | Input(ClusterDropdown.get_id(ALL), "value"), 65 | ), 66 | lambda i: dict(axes=dict(x=i[0]), classes=i[1] or []), 67 | ) 68 | 69 | ColumnDropdown.register_callback( 70 | app, 71 | cls.get_id(ALL, "x_axis_dropdown"), 72 | df_from_store, 73 | numeric=True, 74 | ) 75 | 76 | return [tmp] 77 | 78 | @staticmethod 79 | def render(x_axis, selected_clusters, hover, df, aux, template=None): 80 | df = merge_df_aux(df, aux) 81 | clusters = get_clusters(aux, df.shape[0]) 82 | if type(selected_clusters) == str: 83 | selected_clusters = [selected_clusters] 84 | if not selected_clusters: 85 | selected_clusters = clusters.categories 86 | 87 | props_dict = {"all": []} 88 | for s in selected_clusters: 89 | if s != "all": 90 | props_dict[s] = [] 91 | 92 | for c, p in zip(clusters, df[x_axis]): 93 | if c != "all" and c in selected_clusters: 94 | props_dict[c].append(p) 95 | props_dict["all"].append(p) 96 | 97 | clusters_col = [] 98 | x = [] 99 | for s in selected_clusters: 100 | clusters_col += [s for _ in props_dict[s]] 101 | x += props_dict[s] 102 | 103 | dff = pd.DataFrame({"Clusters": clusters_col, x_axis: x}) 104 | 105 | fig_property = px.histogram( 106 | dff, 107 | x=x_axis, 108 | color="Clusters", 109 | hover_data={ 110 | "Clusters": False, 111 | x_axis: False, 112 | }, 113 | color_discrete_map=cluster_colours(), 114 | opacity=0.5, 115 | histnorm="probability density", 116 | template=template, 117 | ) 118 | 119 | fig_property.update_layout( 120 | hovermode="x unified", 121 | showlegend=False, 122 | barmode="overlay", 123 | yaxis=dict( 124 | tickformat=".2%", 125 | ), 126 | ) 127 | 128 | if hover is not None: 129 | fig_property.add_vline( 130 | df[x_axis][hover], 131 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 132 | layer="below", 133 | ) 134 | if SELECTED_COLUMN_NAME in aux: 135 | color = "#DDD" if template and "dark" in template else "#333" 136 | for x in df[x_axis][aux[SELECTED_COLUMN_NAME]]: 137 | fig_property.add_vline( 138 | x, line=dict(color=color, width=0.5), layer="below" 139 | ) 140 | 141 | return fig_property 142 | 143 | @classmethod 144 | def create_layout(cls, index, df, columns, config=dict()): 145 | import jsonschema 146 | 147 | jsonschema.validate( 148 | instance=config, 149 | schema=dict( 150 | type="object", 151 | properties=dict( 152 | axes=dict( 153 | type="object", properties=dict(x=dict(type="string")) 154 | ), 155 | classes=dict( 156 | type="array", 157 | items=dict(enum=list(cluster_colours().keys())), 158 | uniqueItems=True, 159 | ), 160 | ), 161 | ), 162 | ) 163 | num_columns = get_numeric_columns(df, columns) 164 | 165 | try: 166 | x_axis = config["axes"]["x"] 167 | except Exception: 168 | x_axis = None 169 | 170 | if x_axis is None and len(num_columns) > 0: 171 | x_axis = num_columns[0] 172 | 173 | if x_axis is None: 174 | raise Exception("The dataframe contains no numeric columns") 175 | 176 | classes = config.get("classes", []) 177 | 178 | return [ 179 | dcc.Graph(id={"type": "histogram", "index": index}), 180 | layout_wrapper( 181 | component=ColumnDropdown( 182 | cls.get_id(index, "x_axis_dropdown"), 183 | value=x_axis, 184 | clearable=False, 185 | options=[x_axis], 186 | ), 187 | css_class="dd-single", 188 | title="x axis", 189 | ), 190 | layout_wrapper( 191 | component=ClusterDropdown( 192 | index=index, multi=True, value=classes, clearable=True 193 | ), 194 | title="Cluster Comparison", 195 | css_class="dd-single", 196 | ), 197 | ] 198 | -------------------------------------------------------------------------------- /xiplot/plots/lineplot.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import plotly.express as px 3 | from dash import ALL, MATCH, Input, Output, State, dcc 4 | from dash.exceptions import PreventUpdate 5 | 6 | from xiplot.plots import APlot 7 | from xiplot.plugin import ( 8 | ID_AUXILIARY, 9 | ID_CLICKED, 10 | ID_HOVERED, 11 | placeholder_figure, 12 | ) 13 | from xiplot.utils.auxiliary import ( 14 | CLUSTER_COLUMN_NAME, 15 | SELECTED_COLUMN_NAME, 16 | decode_aux, 17 | merge_df_aux, 18 | toggle_selected, 19 | ) 20 | from xiplot.utils.cluster import cluster_colours 21 | from xiplot.utils.components import ( 22 | ColumnDropdown, 23 | FlexRow, 24 | PdfButton, 25 | PlotData, 26 | ) 27 | from xiplot.utils.dataframe import get_default_column 28 | from xiplot.utils.layouts import layout_wrapper 29 | 30 | 31 | class Lineplot(APlot): 32 | @classmethod 33 | def register_callbacks(cls, app, df_from_store, df_to_store): 34 | PdfButton.register_callback(app, cls.name(), cls.get_id(MATCH)) 35 | 36 | @app.callback( 37 | Output(cls.get_id(MATCH), "figure"), 38 | Input(cls.get_id(MATCH, "x_axis"), "value"), 39 | Input(cls.get_id(MATCH, "y_axis"), "value"), 40 | Input(cls.get_id(MATCH, "color"), "value"), 41 | Input(ID_HOVERED, "data"), 42 | Input("data_frame_store", "data"), 43 | Input("auxiliary_store", "data"), 44 | Input("plotly-template", "data"), 45 | prevent_initial_call=False, 46 | ) 47 | def render( 48 | x_axis, 49 | y_axis, 50 | color, 51 | hover, 52 | df, 53 | aux, 54 | template=None, 55 | ): 56 | return cls.render( 57 | df_from_store(df), 58 | decode_aux(aux), 59 | x_axis, 60 | y_axis, 61 | color, 62 | hover, 63 | template, 64 | ) 65 | 66 | def get_row(hover): 67 | try: 68 | for p in hover: 69 | if p is not None: 70 | return p["points"][0]["customdata"][0] 71 | except (KeyError, TypeError): 72 | raise PreventUpdate() 73 | raise PreventUpdate() 74 | 75 | @app.callback( 76 | Output(ID_HOVERED, "data"), 77 | Output(cls.get_id(ALL), "hoverData"), 78 | Input(cls.get_id(ALL), "hoverData"), 79 | ) 80 | def handle_hover_events(hover): 81 | return get_row(hover), [None] * len(hover) 82 | 83 | @app.callback( 84 | Output(ID_AUXILIARY, "data"), 85 | Output(ID_CLICKED, "data"), 86 | Output(cls.get_id(ALL), "clickData"), 87 | Input(cls.get_id(ALL), "clickData"), 88 | State(ID_AUXILIARY, "data"), 89 | ) 90 | def handle_click_events(click, aux): 91 | row = get_row(click) 92 | if aux is None: 93 | return dash.no_update, row 94 | return toggle_selected(aux, row), row, [None] * len(click) 95 | 96 | PlotData.register_callback( 97 | cls.name(), 98 | app, 99 | { 100 | "x_axis": Input(cls.get_id(MATCH, "x_axis"), "value"), 101 | "y_axis": Input(cls.get_id(MATCH, "y_axis"), "value"), 102 | "color": Input(cls.get_id(MATCH, "color"), "value"), 103 | }, 104 | ) 105 | 106 | ColumnDropdown.register_callback( 107 | app, cls.get_id(ALL, "x_axis"), df_from_store, numeric=True 108 | ) 109 | ColumnDropdown.register_callback( 110 | app, cls.get_id(ALL, "y_axis"), df_from_store, numeric=True 111 | ) 112 | ColumnDropdown.register_callback( 113 | app, cls.get_id(ALL, "color"), df_from_store, category=True 114 | ) 115 | 116 | return render, handle_hover_events, handle_click_events 117 | 118 | @staticmethod 119 | def render( 120 | df, 121 | aux, 122 | x_axis, 123 | y_axis, 124 | color=None, 125 | hover=None, 126 | template=None, 127 | ): 128 | df = merge_df_aux(df, aux) 129 | df["__Xiplot_index__"] = range(df.shape[0]) 130 | if x_axis not in df.columns or y_axis not in df.columns: 131 | return placeholder_figure("Please select x and y axis") 132 | if color not in df.columns: 133 | color = None 134 | fig = px.line( 135 | df.sort_values(by=x_axis), 136 | x=x_axis, 137 | y=y_axis, 138 | color=color, 139 | color_discrete_map=cluster_colours(), 140 | custom_data=["__Xiplot_index__"], 141 | template=template, 142 | ) 143 | if hover is not None: 144 | fig.add_vline( 145 | df[x_axis][hover], 146 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 147 | layer="below", 148 | ) 149 | fig.add_hline( 150 | df[y_axis][hover], 151 | line=dict(color="rgba(0.5,0.5,0.5,0.5)", dash="dash"), 152 | layer="below", 153 | ) 154 | if SELECTED_COLUMN_NAME in aux: 155 | df2 = df[aux[SELECTED_COLUMN_NAME]] 156 | if not df2.empty: 157 | trace = px.scatter(df2, x=x_axis, y=y_axis) 158 | color = "#DDD" if template and "dark" in template else "#333" 159 | trace.update_traces( 160 | hoverinfo="skip", 161 | hovertemplate=None, 162 | marker=dict(size=15, color=color), 163 | ) 164 | fig.add_traces(trace.data) 165 | return fig 166 | 167 | @classmethod 168 | def create_layout(cls, index, df, columns=None, config=dict()): 169 | num_columns = ColumnDropdown.get_columns(df, numeric=True) 170 | cat_columns = ColumnDropdown.get_columns(df, category=True) 171 | 172 | x_axis = config.get("x_axis", get_default_column(num_columns, "x")) 173 | y_axis = config.get("y_axis", get_default_column(num_columns, "y")) 174 | color = config.get("color", CLUSTER_COLUMN_NAME) 175 | 176 | return [ 177 | dcc.Graph(id=cls.get_id(index)), 178 | FlexRow( 179 | layout_wrapper( 180 | component=ColumnDropdown( 181 | cls.get_id(index, "x_axis"), 182 | options=num_columns, 183 | value=x_axis, 184 | clearable=False, 185 | ), 186 | css_class="dash-dropdown", 187 | title="X", 188 | ), 189 | layout_wrapper( 190 | component=ColumnDropdown( 191 | cls.get_id(index, "y_axis"), 192 | options=num_columns, 193 | value=y_axis, 194 | clearable=False, 195 | ), 196 | css_class="dash-dropdown", 197 | title="Y", 198 | ), 199 | layout_wrapper( 200 | component=ColumnDropdown( 201 | cls.get_id(index, "color"), 202 | options=cat_columns, 203 | value=color, 204 | clearable=True, 205 | ), 206 | css_class="dash-dropdown", 207 | title="Groups", 208 | ), 209 | ), 210 | ] 211 | -------------------------------------------------------------------------------- /xiplot/plots/smiles.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | from dash import MATCH, Input, Output, State, dcc, html 4 | 5 | from xiplot.plots import APlot 6 | from xiplot.plugin import ID_CLICKED, ID_DATAFRAME, ID_HOVERED 7 | from xiplot.utils.components import FlexRow, InputText, PlotData 8 | from xiplot.utils.layouts import layout_wrapper 9 | 10 | 11 | class Smiles(APlot): 12 | @classmethod 13 | def name(cls): 14 | return "SMILES (molecules)" 15 | 16 | @classmethod 17 | def help(cls): 18 | return ( 19 | "Render a molecule from a SMILES string\n\nOne column of the" 20 | " dataset must contain a molecule represented as a SMILES string" 21 | " (Simplified molecular-input line-entry system)." 22 | ) 23 | 24 | @classmethod 25 | def register_callbacks(cls, app, df_from_store, df_to_store): 26 | app.clientside_callback( 27 | """ 28 | async function svgFromSMILES(smiles) { 29 | if (!window.RDKit) { 30 | window.RDKit = await window.initRDKitModule(); 31 | } 32 | const INVALID_SVG = ` 33 | 34 | 41 | 42 | 43 | 44 | `; 45 | const mol = window.RDKit.get_mol(smiles); 46 | const svg = smiles && mol 47 | .get_svg() 48 | .replace(/]*>\\s*<\\/rect>/, "") 49 | .split(/\\s+/) 50 | .join(" "); 51 | 52 | return "data:image/svg+xml;base64," + btoa(svg || INVALID_SVG); 53 | } 54 | """, 55 | Output(cls.get_id(MATCH, "display"), "src"), 56 | Input(cls.get_id(MATCH, "string"), "value"), 57 | prevent_initial_call=False, 58 | ) 59 | 60 | @app.callback( 61 | Output(cls.get_id(MATCH, "string"), "value"), 62 | Input(ID_CLICKED, "data"), 63 | Input(ID_HOVERED, "data"), 64 | State(cls.get_id(MATCH, "mode"), "value"), 65 | State(cls.get_id(MATCH, "col"), "value"), 66 | State(cls.get_id(MATCH, "string"), "value"), 67 | State(ID_DATAFRAME, "data"), 68 | prevent_initial_call=True, 69 | ) 70 | def update_smiles(rowc, rowh, mode, col, old, df): 71 | if col is None or col == "": 72 | return dash.no_update 73 | if mode == "Click": 74 | row = rowc 75 | elif mode == "Hover": 76 | row = rowh 77 | else: 78 | raise Exception("Unknown SMILES mode: " + mode) 79 | if row is None: 80 | return dash.no_update 81 | df = df_from_store(df) 82 | try: 83 | new = df[col][row] 84 | if old != new: 85 | return new 86 | return dash.no_update 87 | except Exception: 88 | return dash.no_update 89 | 90 | PlotData.register_callback( 91 | cls.name(), 92 | app, 93 | dict( 94 | mode=Input(cls.get_id(MATCH, "mode"), "value"), 95 | smiles=Input(cls.get_id(MATCH, "string"), "value"), 96 | column=Input(cls.get_id(MATCH, "col"), "value"), 97 | ), 98 | ) 99 | 100 | return update_smiles 101 | 102 | @classmethod 103 | def create_layout(cls, index, df: pd.DataFrame, columns, config=dict()): 104 | import jsonschema 105 | 106 | jsonschema.validate( 107 | instance=config, 108 | schema=dict( 109 | type="object", 110 | properties=dict( 111 | mode=dict(enum=["Hover", "Click"]), 112 | smiles=dict(type="string"), 113 | column=dict(type="string"), 114 | ), 115 | ), 116 | ) 117 | 118 | cols = [ 119 | c 120 | for c in df.select_dtypes([object, "category"]) 121 | if isinstance(df[c][0], str) 122 | ] 123 | column = next((c for c in cols if "smiles" in c.lower()), "") 124 | 125 | render_mode = config.get("mode", "Hover") 126 | smiles_input = config.get("smiles", "") 127 | column = config.get("column", column) 128 | 129 | return [ 130 | html.Img( 131 | id=cls.get_id(index, "display"), 132 | className="smiles-img", 133 | ), 134 | html.Br(), 135 | FlexRow( 136 | layout_wrapper( 137 | dcc.Dropdown( 138 | id=cls.get_id(index, "col"), value=column, options=cols 139 | ), 140 | title="SMILES column", 141 | css_class="dash-dropdown", 142 | ), 143 | layout_wrapper( 144 | dcc.Dropdown( 145 | id=cls.get_id(index, "mode"), 146 | value=render_mode, 147 | options=["Hover", "Click"], 148 | clearable=False, 149 | searchable=False, 150 | ), 151 | title="Selection mode", 152 | css_class="dash-dropdown", 153 | ), 154 | layout_wrapper( 155 | InputText( 156 | id=cls.get_id(index, "string"), 157 | value=smiles_input, 158 | debounce=True, 159 | placeholder="SMILES string, e.g., 'O.O[Fe]=O'", 160 | ), 161 | css_class="dash-input", 162 | title="SMILES string", 163 | ), 164 | ), 165 | ] 166 | -------------------------------------------------------------------------------- /xiplot/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module exposes the parts of xiplot that are useful for implementing plugins. 3 | Most of the members of this module are just imported from other places in xiplot. 4 | """ 5 | 6 | from io import BytesIO 7 | from os import PathLike 8 | from typing import Any, Callable, Dict, Optional, Tuple, Union 9 | 10 | import pandas as pd 11 | from dash import Dash 12 | from dash.development.base_component import Component 13 | 14 | # Export useful parts of xiplot: 15 | from xiplot.plots import APlot # noqa: F401 16 | from xiplot.utils import generate_id # noqa: F401 17 | from xiplot.utils.auxiliary import ( # noqa: F401 18 | decode_aux, 19 | encode_aux, 20 | get_clusters, 21 | get_selected, 22 | merge_df_aux, 23 | ) 24 | from xiplot.utils.cluster import cluster_colours # noqa: F401 25 | from xiplot.utils.components import ( # noqa: F401 26 | ClusterDropdown, 27 | ColumnDropdown, 28 | DeleteButton, 29 | FlexRow, 30 | HelpButton, 31 | PdfButton, 32 | PlotData, 33 | ) 34 | 35 | # IDs for important `dcc.Store` components: 36 | ID_DATAFRAME = "data_frame_store" # Main dataframe (readonly) 37 | ID_AUXILIARY = "auxiliary_store" # Additional dataframe (editable) 38 | ID_METADATA = "metadata_store" 39 | ID_HOVERED = "lastly_hovered_point_store" 40 | ID_CLICKED = "lastly_clicked_point_store" 41 | # `dcc.Store` that is `True` if the dark mode is active 42 | ID_DARK_MODE = "light-dark-toggle-store" 43 | # `dcc.Store` that contains the current plotly template (used for the dark mode) 44 | ID_PLOTLY_TEMPLATE = "plotly-template" 45 | 46 | # Useful CSS classes 47 | CSS_STRETCH_CLASS = "stretch" 48 | CSS_LARGE_BUTTON_CLASS = "button" 49 | CSS_DELETE_BUTTON_CLASS = "delete" 50 | 51 | 52 | # Helpful typehints: 53 | AReadFunction = Callable[[Union[BytesIO, PathLike]], pd.DataFrame] 54 | # Read plugin return string: file extension 55 | AReadPlugin = Callable[[], Optional[Tuple[AReadFunction, str]]] 56 | AWriteFunction = Callable[[pd.DataFrame, BytesIO], None] 57 | # Write plugin return strings: file extension, MIME type 58 | # (ususally "application/octet-stream") 59 | AWritePlugin = Callable[[], Optional[Tuple[AWriteFunction, str, str]]] 60 | AGlobalPlugin = Callable[[], Component] 61 | # Callback plugin functions: parse_to_dataframe, serialise_from_dataframe 62 | ACallbackPlugin = Callable[ 63 | [Dash, Callable[[Any], pd.DataFrame], Callable[[pd.DataFrame], Any]], None 64 | ] 65 | 66 | 67 | # Helper functions: 68 | def placeholder_figure(text: str) -> Dict[str, Any]: 69 | """Display a placeholder text instead of a graph. 70 | This can be used in a "callback" function when a graph cannot be rendered. 71 | 72 | Args: 73 | text: Placeholder text. 74 | 75 | Returns: 76 | Dash figure (to place into a `Output(dcc.Graph.id, "figure")`). 77 | """ 78 | return { 79 | "layout": { 80 | "xaxis": {"visible": False}, 81 | "yaxis": {"visible": False}, 82 | "annotations": [ 83 | { 84 | "text": text, 85 | "xref": "paper", 86 | "yref": "paper", 87 | "showarrow": False, 88 | "font": {"size": 28}, 89 | } 90 | ], 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /xiplot/setup.py: -------------------------------------------------------------------------------- 1 | import dash_extensions.enrich as enrich 2 | import pandas as pd 3 | from dash_extensions.enrich import ( 4 | CycleBreakerTransform, 5 | DashProxy, 6 | MultiplexerTransform, 7 | ServersideOutputTransform, 8 | ) 9 | 10 | from xiplot.app import XiPlot 11 | from xiplot.utils.store import ServerSideStoreBackend 12 | 13 | 14 | def setup_xiplot_dash_app( 15 | unsafe_local_server=False, data_dir="", plugin_dir="", **kwargs 16 | ): 17 | dash_transforms = [ 18 | MultiplexerTransform(), 19 | CycleBreakerTransform(), 20 | ] 21 | 22 | if unsafe_local_server: 23 | dash_transforms.append( 24 | ServersideOutputTransform( 25 | backend=ServerSideStoreBackend(), 26 | session_check=False, 27 | arg_check=False, 28 | ) 29 | ) 30 | 31 | def df_from_store(df): 32 | if isinstance(df, pd.DataFrame): 33 | return df.copy(deep=False) 34 | return df 35 | 36 | def df_to_store(df): 37 | return df 38 | 39 | else: 40 | 41 | def df_from_store(df): 42 | return pd.read_json(df, orient="split") 43 | 44 | def df_to_store(df): 45 | return df.to_json(date_format="iso", orient="split") 46 | 47 | dash = DashProxy( 48 | "xiplot.app", 49 | suppress_callback_exceptions=True, 50 | transforms=dash_transforms, 51 | prevent_initial_callbacks=True, 52 | **kwargs, 53 | ) 54 | 55 | _app = XiPlot( # noqa: F841 56 | app=dash, 57 | df_from_store=df_from_store, 58 | df_to_store=df_to_store, 59 | data_dir=data_dir, 60 | plugin_dir=plugin_dir, 61 | ) 62 | 63 | return dash 64 | 65 | 66 | """ Monkey patch for dash_extensions to remove print """ 67 | 68 | 69 | # https://github.com/thedirtyfew/dash-extensions/commit/d843b63 70 | def _new_get_cache_id(func, output, args, session_check=None, arg_check=True): 71 | all_args = [func.__name__, enrich._create_callback_id(output)] 72 | if arg_check: 73 | all_args += list(args) 74 | if session_check: 75 | all_args += [enrich._get_session_id()] 76 | return enrich.hashlib.md5(enrich.json.dumps(all_args).encode()).hexdigest() 77 | 78 | 79 | enrich._get_cache_id = _new_get_cache_id 80 | -------------------------------------------------------------------------------- /xiplot/tabs/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractstaticmethod 2 | 3 | 4 | class Tab(ABC): 5 | def __init__(self): 6 | raise TypeError("Tabs should not be constructed") 7 | 8 | @classmethod 9 | def name(cls) -> str: 10 | return cls.__name__ 11 | 12 | @abstractstaticmethod 13 | def register_callbacks(app, df_from_store, df_to_store): 14 | pass 15 | 16 | @abstractstaticmethod 17 | def create_layout(): 18 | pass 19 | 20 | @abstractstaticmethod 21 | def create_layout_globals(): 22 | pass 23 | -------------------------------------------------------------------------------- /xiplot/tabs/embedding.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import uuid 3 | 4 | import dash 5 | import dash_mantine_components as dmc 6 | from dash import Input, Output, State, dcc, html 7 | 8 | from xiplot.tabs import Tab 9 | from xiplot.utils.auxiliary import decode_aux, encode_aux, merge_df_aux 10 | from xiplot.utils.components import ColumnDropdown, FlexRow 11 | from xiplot.utils.layouts import layout_wrapper 12 | from xiplot.utils.regex import get_columns_by_regex 13 | 14 | 15 | def get_pca_columns(df, features): 16 | from sklearn.decomposition import PCA 17 | from sklearn.impute import SimpleImputer 18 | from sklearn.preprocessing import StandardScaler 19 | 20 | x = df[features] 21 | x = StandardScaler().fit_transform(x) 22 | 23 | mean_imputer = SimpleImputer(strategy="mean") 24 | x = mean_imputer.fit_transform(x) 25 | 26 | pca = PCA(n_components=2) 27 | pca.fit(x) 28 | return pca.transform(x) 29 | 30 | 31 | class Embedding(Tab): 32 | def register_callbacks(app, df_from_store, df_to_store): 33 | @app.callback( 34 | Output("auxiliary_store", "data"), 35 | Output("embedding-tab-main-notify-container", "children"), 36 | Output("embedding-tab-compute-done", "children"), 37 | Input("embedding-button", "value"), 38 | State("data_frame_store", "data"), 39 | State("auxiliary_store", "data"), 40 | State("embedding_feature", "value"), 41 | State("embedding_type", "value"), 42 | ) 43 | def compute_embedding(process_id, df, aux, features, embedding_type): 44 | if df is None: 45 | return ( 46 | dash.no_update, 47 | dmc.Notification( 48 | id=str(uuid.uuid4()), 49 | color="yellow", 50 | title="Warning", 51 | message="You have not yet loaded any data file.", 52 | action="show", 53 | autoClose=10000, 54 | ), 55 | dash.no_update, 56 | ) 57 | 58 | df = df_from_store(df) 59 | aux = decode_aux(aux) 60 | columns = ColumnDropdown.get_columns(df, aux, numeric=True) 61 | features = get_columns_by_regex(columns, features) 62 | 63 | notifications = [] 64 | if not Embedding.validate_embedding_params( 65 | embedding_type, features, notifications, process_id 66 | ): 67 | return dash.no_update, notifications, process_id 68 | 69 | try: 70 | pca_cols = get_pca_columns(merge_df_aux(df, aux), features) 71 | aux["Xiplot_PCA_1"] = pca_cols[:, 0] 72 | aux["Xiplot_PCA_2"] = pca_cols[:, 1] 73 | 74 | notifications.append( 75 | dmc.Notification( 76 | id=process_id or str(uuid.uuid4()), 77 | color="green", 78 | title="Success", 79 | message="The data was embedded successfully!", 80 | action="update" if process_id else "show", 81 | autoClose=5000, 82 | disallowClose=False, 83 | ) 84 | ) 85 | 86 | except ImportError as err: 87 | raise err 88 | 89 | except Exception as err: 90 | notifications.append( 91 | dmc.Notification( 92 | id=process_id or str(uuid.uuid4()), 93 | color="red", 94 | title="Error", 95 | message=( 96 | "The embedding failed with an internal error." 97 | f" Please report the following bug: {err}" 98 | ), 99 | action="update" if process_id else "show", 100 | autoClose=False, 101 | ) 102 | ) 103 | 104 | return (dash.no_update, notifications, process_id) 105 | 106 | return encode_aux(aux), notifications, process_id 107 | 108 | @app.callback( 109 | Output("embedding-button", "value"), 110 | Output("embedding-tab-compute-notify-container", "children"), 111 | Input("embedding-button", "n_clicks"), 112 | State("embedding_type", "value"), 113 | State("embedding_feature", "value"), 114 | State("embedding-tab-compute-done", "children"), 115 | State("embedding-button", "value"), 116 | ) 117 | def compute_embeddings( 118 | n_clicks, embedding_type, features, done, doing 119 | ): 120 | if done != doing: 121 | return dash.no_update, dmc.Notification( 122 | id=str(uuid.uuid4()), 123 | color="yellow", 124 | title="Warning", 125 | message=( 126 | "The k-means clustering process has not yet finished." 127 | ), 128 | action="show", 129 | autoClose=10000, 130 | ) 131 | 132 | process_id = str(uuid.uuid4()) 133 | 134 | message = "The embedding process has started." 135 | 136 | if "sklearn" not in sys.modules: 137 | message += " [Loading scikit-learn]" 138 | 139 | return process_id, dmc.Notification( 140 | id=process_id, 141 | color="blue", 142 | title="Processing", 143 | message=message, 144 | action="show", 145 | loading=True, 146 | autoClose=False, 147 | disallowClose=True, 148 | ) 149 | 150 | ColumnDropdown.register_callback( 151 | app, 152 | "embedding_feature", 153 | df_from_store, 154 | numeric=True, 155 | regex_button_id="embedding-regex-button", 156 | regex_input_id="embedding-regex-input", 157 | ) 158 | 159 | @staticmethod 160 | def validate_embedding_params( 161 | embedding_type, features, notifications=None, process_id=None 162 | ): 163 | if features is None: 164 | if notifications is not None and process_id is not None: 165 | notifications.append( 166 | dmc.Notification( 167 | id=process_id, 168 | action="hide", 169 | ) 170 | ) 171 | 172 | if features is None and notifications is not None: 173 | notifications.append( 174 | dmc.Notification( 175 | id=str(uuid.uuid4()), 176 | color="yellow", 177 | title="Warning", 178 | message=( 179 | "You have not selected any features to embed by." 180 | ), 181 | action="show", 182 | autoClose=10000, 183 | ) 184 | ) 185 | 186 | if embedding_type is None and notifications is not None: 187 | notifications.append( 188 | dmc.Notification( 189 | id=str(uuid.uuid4()), 190 | color="yellow", 191 | title="Warning", 192 | message="You have not selected embedding type.", 193 | action="show", 194 | autoClose=10000, 195 | ) 196 | ) 197 | 198 | return False 199 | 200 | if len(features) < 2 and embedding_type == "PCA": 201 | if notifications is not None and process_id is not None: 202 | notifications.append( 203 | dmc.Notification( 204 | id=process_id, 205 | action="hide", 206 | ) 207 | ) 208 | 209 | if notifications is not None: 210 | notifications.append( 211 | dmc.Notification( 212 | id=str(uuid.uuid4()), 213 | color="yellow", 214 | title="Warning", 215 | message="You must select at least two features", 216 | action="show", 217 | autoClose=10000, 218 | ) 219 | ) 220 | 221 | return False 222 | 223 | return True 224 | 225 | @staticmethod 226 | def create_layout(): 227 | return FlexRow( 228 | layout_wrapper( 229 | dcc.Dropdown( 230 | id="embedding_type", 231 | options=["PCA"], 232 | value="PCA", 233 | clearable=False, 234 | ), 235 | css_class="dash-dropdown", 236 | title="Embedding type", 237 | ), 238 | layout_wrapper( 239 | component=ColumnDropdown(id="embedding_feature", multi=True), 240 | css_class="dash-dropdown", 241 | title="Features", 242 | ), 243 | dcc.Input(id="embedding-regex-input", style={"display": "none"}), 244 | html.Button( 245 | "Add features by regex", 246 | id="embedding-regex-button", 247 | className="button", 248 | ), 249 | html.Button( 250 | "Compute the embedding", 251 | id="embedding-button", 252 | className="button", 253 | ), 254 | id="control-embedding-container", 255 | ) 256 | 257 | @staticmethod 258 | def create_layout_globals(): 259 | return html.Div( 260 | [ 261 | html.Div( 262 | id="embedding-tab-main-notify-container", 263 | style={"display": "none"}, 264 | ), 265 | html.Div( 266 | id="embedding-tab-compute-notify-container", 267 | style={"display": "none"}, 268 | ), 269 | html.Div( 270 | id="embedding-tab-compute-done", style={"display": "none"} 271 | ), 272 | ], 273 | style={"display": "none"}, 274 | ) 275 | -------------------------------------------------------------------------------- /xiplot/tabs/plots.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import dash 4 | import dash_mantine_components as dmc 5 | from dash import ALL, Input, Output, State, ctx, dcc, html 6 | from dash_extensions.enrich import CycleBreakerInput 7 | 8 | from xiplot.plots.barplot import Barplot 9 | from xiplot.plots.boxplot import Boxplot 10 | from xiplot.plots.distplot import Distplot 11 | from xiplot.plots.heatmap import Heatmap 12 | from xiplot.plots.histogram import Histogram 13 | from xiplot.plots.lineplot import Lineplot 14 | from xiplot.plots.scatterplot import Scatterplot 15 | from xiplot.plots.smiles import Smiles 16 | from xiplot.plots.table import Table 17 | from xiplot.tabs import Tab 18 | from xiplot.tabs.plugins import get_plugins_cached 19 | from xiplot.utils import generate_id 20 | from xiplot.utils.auxiliary import merge_df_aux 21 | from xiplot.utils.components import DeleteButton, FlexRow 22 | from xiplot.utils.layouts import layout_wrapper 23 | 24 | 25 | class Plots(Tab): 26 | @staticmethod 27 | def get_plot_types(): 28 | return { 29 | p.name(): p 30 | for p in [ 31 | Scatterplot, 32 | Lineplot, 33 | Histogram, 34 | Distplot, 35 | Barplot, 36 | Boxplot, 37 | Heatmap, 38 | Table, 39 | Smiles, 40 | ] 41 | + [plot for (_, _, plot) in get_plugins_cached("plot")] 42 | } 43 | 44 | @staticmethod 45 | def register_callbacks(app, df_from_store, df_to_store): 46 | for plot_name, plot_type in Plots.get_plot_types().items(): 47 | plot_type.register_callbacks( 48 | app, df_from_store=df_from_store, df_to_store=df_to_store 49 | ) 50 | 51 | @app.callback( 52 | Output("plots-tab-settings-session", "children"), 53 | Output("plots", "children"), 54 | Output("metadata_store", "data"), 55 | Output("plots-tab-notify-container", "children"), 56 | Input("new_plot-button", "n_clicks"), 57 | Input(generate_id(DeleteButton, ALL), "n_clicks"), 58 | State("plots", "children"), 59 | State("plot_type", "value"), 60 | State("data_frame_store", "data"), 61 | State("auxiliary_store", "data"), 62 | CycleBreakerInput("metadata_store", "data"), 63 | State("plots-tab-settings-session", "children"), 64 | ) 65 | def add_new_plot( 66 | n_clicks, 67 | deletion, 68 | children, 69 | plot_type, 70 | df, 71 | aux, 72 | meta, 73 | last_meta_session, 74 | ): 75 | plot_types = Plots.get_plot_types() 76 | 77 | if meta is None: 78 | if ctx.triggered_id == "new_plot-button": 79 | return ( 80 | dash.no_update, 81 | dash.no_update, 82 | dash.no_update, 83 | dmc.Notification( 84 | id=str(uuid.uuid4()), 85 | color="yellow", 86 | title="Warning", 87 | message="You have not yet loaded any data file.", 88 | action="show", 89 | autoClose=10000, 90 | ), 91 | ) 92 | else: 93 | return ( 94 | dash.no_update, 95 | dash.no_update, 96 | dash.no_update, 97 | dash.no_update, 98 | ) 99 | 100 | if not children: 101 | children = [] 102 | 103 | if ctx.triggered_id in [ 104 | "new_plot-button", 105 | "metadata_store_data_breaker", 106 | ]: 107 | if df is None: 108 | return ( 109 | meta["session"], 110 | dash.no_update, 111 | dash.no_update, 112 | dmc.Notification( 113 | id=str(uuid.uuid4()), 114 | color="yellow", 115 | title="Warning", 116 | message="You have not yet loaded any data file.", 117 | action="show", 118 | autoClose=10000, 119 | ), 120 | ) 121 | 122 | if ctx.triggered_id == "new_plot-button": 123 | if not plot_type: 124 | return ( 125 | meta["session"], 126 | dash.no_update, 127 | dash.no_update, 128 | dmc.Notification( 129 | id=str(uuid.uuid4()), 130 | color="yellow", 131 | title="Warning", 132 | message="You have not selected any plot type.", 133 | action="show", 134 | autoClose=10000, 135 | ), 136 | ) 137 | 138 | plots = {str(uuid.uuid4()): dict(type=plot_type)} 139 | elif meta["session"] == last_meta_session: 140 | return ( 141 | dash.no_update, 142 | dash.no_update, 143 | dash.no_update, 144 | dash.no_update, 145 | ) 146 | else: 147 | import jsonschema 148 | 149 | children = [] 150 | try: 151 | jsonschema.validate( 152 | instance=meta, 153 | schema=dict( 154 | type="object", 155 | properties=dict( 156 | plots=dict( 157 | type="object", 158 | patternProperties={ 159 | r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$": dict( # noqa: 501 160 | type="object", 161 | properties=dict( 162 | type=dict(type="string") 163 | ), 164 | required=["type"], 165 | ), 166 | }, 167 | additionalProperties=False, 168 | ), 169 | ), 170 | required=["plots"], 171 | ), 172 | ) 173 | except jsonschema.exceptions.ValidationError as err: 174 | return ( 175 | meta["session"], 176 | dash.no_update, 177 | dash.no_update, 178 | dmc.Notification( 179 | id=str(uuid.uuid4()), 180 | color="yellow", 181 | title="Warning", 182 | message=( 183 | "Invalid plots configuration at" 184 | f" meta{err.json_path[1:]}: {err.message}." 185 | ), 186 | action="show", 187 | autoClose=10000, 188 | ), 189 | ) 190 | 191 | plots = meta["plots"] 192 | 193 | # read df from store 194 | df = merge_df_aux(df_from_store(df), aux) 195 | 196 | columns = df.columns.to_list() 197 | 198 | notifications = [] 199 | 200 | import jsonschema 201 | 202 | for index, config in plots.items(): 203 | plot_type = config["type"] 204 | config = {k: v for k, v in config.items() if k != "type"} 205 | 206 | if plot_type not in plot_types: 207 | notifications.append( 208 | dmc.Notification( 209 | id=str(uuid.uuid4()), 210 | color="yellow", 211 | title="Warning", 212 | message=f"Unknown plot type: {plot_type}", 213 | action="show", 214 | autoClose=10000, 215 | ) 216 | ) 217 | continue 218 | 219 | try: 220 | layout = plot_types[plot_type].create_new_layout( 221 | index, 222 | df, 223 | columns, 224 | config=config, 225 | ) 226 | 227 | children.append(layout) 228 | except jsonschema.exceptions.ValidationError as err: 229 | notifications.append( 230 | dmc.Notification( 231 | id=str(uuid.uuid4()), 232 | color="yellow", 233 | title="Warning", 234 | message=( 235 | "Invalid configuration for a" 236 | f" {plot_type} at" 237 | f" meta.plots.{index}{err.json_path[1:]}:" 238 | f" {err.message}" 239 | ), 240 | action="show", 241 | autoClose=10000, 242 | ) 243 | ) 244 | except ImportError as err: 245 | raise err 246 | except Exception as err: 247 | notifications.append( 248 | dmc.Notification( 249 | id=str(uuid.uuid4()), 250 | color="yellow", 251 | title="Warning", 252 | message=( 253 | f"Failed to create a {plot_type}: {err}." 254 | ), 255 | action="show", 256 | autoClose=10000, 257 | ) 258 | ) 259 | 260 | if ctx.triggered_id == "new_plot-button": 261 | return ( 262 | meta["session"], 263 | children, 264 | dash.no_update, 265 | notifications, 266 | ) 267 | else: 268 | meta["plots"].clear() 269 | return meta["session"], children, meta, notifications 270 | 271 | deletion_id = ctx.triggered_id["index"] 272 | 273 | children = [ 274 | chart 275 | for chart in children 276 | if chart["props"]["id"]["index"] != deletion_id 277 | ] 278 | 279 | return meta["session"], children, dash.no_update, None 280 | 281 | @staticmethod 282 | def create_layout(): 283 | plots = list(Plots.get_plot_types().keys()) 284 | return html.Div( 285 | [ 286 | layout_wrapper( 287 | component=FlexRow( 288 | dcc.Dropdown( 289 | plots, plots[0], id="plot_type", clearable=False 290 | ), 291 | html.Button( 292 | "Add", id="new_plot-button", className="button" 293 | ), 294 | ), 295 | title="Select a plot type", 296 | ), 297 | ], 298 | id="control_plots_content-container", 299 | ) 300 | 301 | @staticmethod 302 | def create_layout_globals(): 303 | return html.Div( 304 | [ 305 | html.Div( 306 | id="plots-tab-notify-container", style={"display": "none"} 307 | ), 308 | html.Div( 309 | id="plots-tab-settings-session", style={"display": "none"} 310 | ), 311 | ], 312 | style={"display": "none"}, 313 | ) 314 | -------------------------------------------------------------------------------- /xiplot/tabs/settings.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import plotly.graph_objects as go 4 | import plotly.io as pio 5 | from dash import Dash, Input, Output, State, dcc, html 6 | 7 | from xiplot.tabs import Tab 8 | from xiplot.tabs.plugins import ( 9 | get_all_loaded_plugins_cached, 10 | is_dynamic_plugin_loading_supported, 11 | ) 12 | from xiplot.utils.components import FlexRow 13 | from xiplot.utils.layouts import layout_wrapper 14 | 15 | 16 | class Settings(Tab): 17 | @staticmethod 18 | def register_callbacks(app: Dash, df_from_store, df_to_store): 19 | pio.templates["xiplot_light"] = go.layout.Template( 20 | layout={ 21 | "paper_bgcolor": "rgba(255,255,255,0)", 22 | "margin": dict(l=10, r=10, t=10, b=10, autoexpand=True), 23 | "uirevision": True, 24 | } 25 | ) 26 | pio.templates["xiplot_dark"] = go.layout.Template( 27 | layout={ 28 | "paper_bgcolor": "rgba(0,0,0,0)", 29 | "margin": dict(l=10, r=10, t=10, b=10, autoexpand=True), 30 | "uirevision": True, 31 | } 32 | ) 33 | pio.templates.default = "plotly_white+xiplot_light" 34 | 35 | app.clientside_callback( 36 | """ 37 | function toggleLightDarkMode(clicks, data) { 38 | if (clicks != undefined) { 39 | data = !data 40 | } 41 | if (data) { 42 | document.documentElement.setAttribute("data-theme", "dark") 43 | return ['Light mode', data, "plotly_dark+xiplot_dark"] 44 | } else { 45 | document.documentElement.setAttribute("data-theme", "light") 46 | return ['Dark mode', data, "plotly_white+xiplot_light"] 47 | } 48 | } 49 | """, 50 | Output("light-dark-toggle", "children"), 51 | Output("light-dark-toggle-store", "data"), 52 | Output("plotly-template", "data"), 53 | Input("light-dark-toggle", "n_clicks"), 54 | State("light-dark-toggle-store", "data"), 55 | prevent_initial_call=False, 56 | ) 57 | 58 | app.clientside_callback( 59 | """ 60 | function changeColSize(cols) { 61 | document.documentElement.setAttribute("data-cols", cols) 62 | return ' ' 63 | } 64 | """, 65 | Output("settings-tab-dummy", "children"), 66 | Input("settings-column-size", "value"), 67 | prevent_initial_call=False, 68 | ) 69 | 70 | app.clientside_callback( 71 | """ 72 | function changePlotHeight(value) { 73 | document.documentElement.setAttribute("plot-height", value) 74 | return ' ' 75 | } 76 | """, 77 | Output("settings-tab-dummy", "children"), 78 | Input("settings-plot-height", "value"), 79 | prevent_initial_call=False, 80 | ) 81 | 82 | @staticmethod 83 | def create_layout(): 84 | return FlexRow( 85 | *[ 86 | layout_wrapper( 87 | component=html.Button( 88 | "Dark mode", 89 | id="light-dark-toggle", 90 | className="light-dark-toggle button", 91 | ), 92 | title="Colour scheme", 93 | ), 94 | html.Span(" ", id="settings-tab-dummy"), 95 | layout_wrapper( 96 | component=dcc.Dropdown( 97 | ["1", "2", "3", "4", "5"], 98 | "3", 99 | clearable=False, 100 | persistence="true", 101 | id="settings-column-size", 102 | ), 103 | title="Maximum number of columns", 104 | ), 105 | html.Span(" "), 106 | layout_wrapper( 107 | component=dcc.Slider( 108 | min=350, 109 | max=650, 110 | step=100, 111 | marks={i: f"{i}" for i in range(350, 651, 100)}, 112 | value=450, 113 | id="settings-plot-height", 114 | persistence="true", 115 | ), 116 | style={"width": "12rem"}, 117 | title="Plot height", 118 | ), 119 | ] 120 | + ( 121 | [] 122 | if is_dynamic_plugin_loading_supported() 123 | else [ 124 | html.Span(" "), 125 | layout_wrapper( 126 | component=FlexRow( 127 | dcc.Dropdown( 128 | get_installed_plugin_options(), 129 | id="installed_plugins", 130 | clearable=False, 131 | searchable=False, 132 | placeholder=( 133 | "Check the list of installed plugins" 134 | ), 135 | ), 136 | ), 137 | title="Installed Plugins", 138 | css_class="dash-dropdown", 139 | ), 140 | ] 141 | ), 142 | id="control-settings-container", 143 | style={"alignItems": "start"}, 144 | ) 145 | 146 | @staticmethod 147 | def create_layout_globals(): 148 | globals = [ 149 | # Store the dark/light state across page reloads 150 | dcc.Store( 151 | id="light-dark-toggle-store", data=False, storage_type="local" 152 | ), 153 | dcc.Store(id="plotly-template", data=None), 154 | ] 155 | return html.Div(globals) 156 | 157 | 158 | def get_installed_plugin_options(): 159 | plugins = defaultdict(set) 160 | 161 | for ( 162 | kind, 163 | name, 164 | path, 165 | plugin, 166 | ) in get_all_loaded_plugins_cached(): 167 | plugins[path.split(":")[0].split(".")[0]].add(kind) 168 | 169 | plugin_options = [] 170 | 171 | for plugin, kinds in plugins.items(): 172 | if len(kinds) > 0: 173 | kinds = f": {', '.join(kinds)}" 174 | else: 175 | kinds = "" 176 | 177 | plugin_options.append( 178 | { 179 | "label": f"{plugin}{kinds}", 180 | "value": plugin, 181 | "disabled": True, 182 | } 183 | ) 184 | 185 | return plugin_options 186 | -------------------------------------------------------------------------------- /xiplot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from dash import ALL, ALLSMALLER, MATCH 4 | 5 | 6 | def generate_id( 7 | cls: type, index: Optional[Any] = None, subtype: Optional[str] = None 8 | ) -> Dict[str, Any]: 9 | """Generate id:s for (dash) objects. 10 | 11 | Since the type of the id is based on an actual type there is less chance 12 | for overloading and better refactoring resistance. 13 | Furthermore, using this function instead of a handcrafted dict leads to 14 | fewer typos. 15 | 16 | Args: 17 | cls: The type for which the id is generated, e.g., a subclass of 18 | `xiplot.plots.APlot`. 19 | index: The index of the id. Defaults to None. 20 | subtype: If multiple id:s are generated for the same class (e.g. 21 | children of a `html.Div`) then this can be used for 22 | differentiation. Defaults to None. 23 | 24 | Returns: 25 | An id to be used with Dash. 26 | """ 27 | if cls in [MATCH, ALL, ALLSMALLER]: 28 | classtype = cls 29 | elif subtype is None: 30 | classtype = f"{cls.__module__}_{cls.__qualname__}".replace(".", "_") 31 | else: 32 | classtype = f"{cls.__module__}_{cls.__qualname__}_{subtype}".replace( 33 | ".", "_" 34 | ) 35 | if index is None: 36 | return {"type": classtype} 37 | else: 38 | return {"type": classtype, "index": index} 39 | -------------------------------------------------------------------------------- /xiplot/utils/auxiliary.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Sequence, Union 2 | 3 | import pandas as pd 4 | 5 | CLUSTER_COLUMN_NAME = "Xiplot_cluster" 6 | SELECTED_COLUMN_NAME = "Xiplot_selected" 7 | 8 | 9 | def get_clusters(aux: pd.DataFrame, n: Optional[int] = None) -> pd.Categorical: 10 | """Get the cluster column from the auxiliary data. 11 | 12 | Args: 13 | aux: Auxiliary data frame. 14 | n: Column size if missing. Defaults to `aux.shape[0]`. 15 | 16 | Returns: 17 | Categorical column with clusters (creates a column with `n` "all" if missing) 18 | """ 19 | if not isinstance(aux, pd.DataFrame): 20 | aux = decode_aux(aux) 21 | if CLUSTER_COLUMN_NAME in aux: 22 | return pd.Categorical(aux[CLUSTER_COLUMN_NAME].copy()) 23 | if n is None: 24 | n = aux.shape[0] 25 | return pd.Categorical(["all"]).repeat(n) 26 | 27 | 28 | def get_selected(aux: pd.DataFrame, n: Optional[int] = None) -> pd.Series: 29 | """Get the selected column from the auxiliary data. 30 | 31 | Args: 32 | aux: Auxiliary data frame. 33 | n: Column size if missing. Defaults to `aux.shape[0]`. 34 | 35 | Returns: 36 | Column with booleans (creates a column with `[False] * n` if missing) 37 | """ 38 | if not isinstance(aux, pd.DataFrame): 39 | aux = decode_aux(aux) 40 | if SELECTED_COLUMN_NAME in aux: 41 | return aux[SELECTED_COLUMN_NAME].copy() 42 | if n is None: 43 | n = aux.shape[0] 44 | return pd.Series([False]).repeat(n).reset_index(drop=True) 45 | 46 | 47 | def toggle_selected( 48 | aux: pd.DataFrame, rows: Sequence[int], n: Optional[int] = None 49 | ) -> pd.DataFrame: 50 | """Toggle rows in the selected column in the auxiliary data. 51 | 52 | Args: 53 | aux: Auxiliary data frame. 54 | rows: Rows to toggle. 55 | n: Column size if missing. Defaults to `aux.shape[0]`. 56 | 57 | Returns: 58 | Updated auxiliary data frame. 59 | """ 60 | encode = not isinstance(aux, pd.DataFrame) 61 | if encode: 62 | aux = decode_aux(aux) 63 | selected = get_selected(aux, n) 64 | if isinstance(rows, int): 65 | rows = (rows,) 66 | for row in rows: 67 | selected[row] = not selected[row] 68 | aux[SELECTED_COLUMN_NAME] = selected 69 | if encode: 70 | aux = encode_aux(aux) 71 | return aux 72 | 73 | 74 | def decode_aux(aux: str) -> pd.DataFrame: 75 | if isinstance(aux, pd.DataFrame): 76 | return aux 77 | return pd.read_json(aux, orient="table") 78 | 79 | 80 | def encode_aux(aux: pd.DataFrame) -> str: 81 | return aux.to_json(orient="table", index=False) 82 | 83 | 84 | def merge_df_aux( 85 | df: pd.DataFrame, aux: Union[str, pd.DataFrame] 86 | ) -> pd.DataFrame: 87 | if not isinstance(aux, pd.DataFrame): 88 | aux = decode_aux(aux) 89 | aux.index = df.index 90 | return pd.concat((df, aux), axis=1) 91 | -------------------------------------------------------------------------------- /xiplot/utils/cli.py: -------------------------------------------------------------------------------- 1 | def cli(): 2 | import argparse 3 | import os 4 | 5 | from xiplot.setup import setup_xiplot_dash_app 6 | 7 | parser = argparse.ArgumentParser( 8 | prog="xiplot", 9 | description="Xiplot: A Dash app for interactively visualising data", 10 | ) 11 | parser.add_argument( 12 | "PATH", 13 | nargs="?", 14 | default="data", 15 | help="The path to a directory containing data files", 16 | ) 17 | parser.add_argument( 18 | "-d", "--debug", action="store_true", help="Enable debug mode" 19 | ) 20 | parser.add_argument( 21 | "-p", "--port", help="Port used to serve the application" 22 | ) 23 | parser.add_argument("--host", help="Host IP used to serve the application") 24 | parser.add_argument( 25 | "-c", 26 | "--cache", 27 | action="store_true", 28 | help=( 29 | "Cache datasets on the server in order to reduce the amount of" 30 | " data transferred. Might not be suitable for servers with" 31 | " multiple users" 32 | ), 33 | ) 34 | parser.add_argument( 35 | "--plugin", help="The path to a directory containing plugin .whl files" 36 | ) 37 | args = parser.parse_args() 38 | path = args.PATH 39 | 40 | if not os.path.isdir(path): 41 | print(f'Directory "{path}" was not found') 42 | 43 | kwargs = {} 44 | if args.debug: 45 | kwargs["debug"] = True 46 | if args.host: 47 | kwargs["host"] = args.host 48 | if args.port: 49 | kwargs["port"] = args.port 50 | if args.plugin: 51 | plugin_dir = args.plugin 52 | else: 53 | plugin_dir = "plugins" 54 | 55 | unsafe_local_server = True if args.cache else False 56 | 57 | app = setup_xiplot_dash_app( 58 | unsafe_local_server=unsafe_local_server, 59 | data_dir=path, 60 | plugin_dir=plugin_dir, 61 | ) 62 | app.run(**kwargs) 63 | -------------------------------------------------------------------------------- /xiplot/utils/cluster.py: -------------------------------------------------------------------------------- 1 | import plotly.express as px 2 | 3 | 4 | def cluster_colours(): 5 | return { 6 | "all": px.colors.qualitative.Vivid[-1], 7 | **{ 8 | f"c{i+1}": c 9 | for i, c in enumerate(px.colors.qualitative.Vivid[:-1]) 10 | }, 11 | } 12 | 13 | 14 | def KMeans(n_clusters: int = 8, **kwargs): 15 | """A wrapper around `sklearn.cluster.KMeans` that changes `n_init="warn"` 16 | to `n_init="auto"`. This is needed to avoid a warning for scikit-learn 17 | versions `>=1.2,<1.4`. Older and newer versions are not affected (unless 18 | `n_init="warn"` is manually specified). 19 | 20 | NOTE: This function lazily loads scikit-learn (so the first call might be slow). 21 | 22 | Args: 23 | n_clusters: The number of clusters. Defaults to 8. 24 | **kwargs: See `sklearn.cluster.KMeans`. 25 | 26 | Returns: 27 | An instance of `sklearn.cluster.KMeans`. 28 | """ 29 | from sklearn.cluster import KMeans 30 | 31 | km = KMeans(n_clusters, **kwargs) 32 | if km.n_init == "warn": 33 | km.n_init = "auto" 34 | return km 35 | -------------------------------------------------------------------------------- /xiplot/utils/dataframe.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tarfile 3 | from collections import OrderedDict 4 | from io import BytesIO 5 | from pathlib import Path 6 | from typing import ( 7 | Any, 8 | Callable, 9 | Dict, 10 | Iterator, 11 | List, 12 | Literal, 13 | Optional, 14 | Tuple, 15 | ) 16 | 17 | import pandas as pd 18 | 19 | from xiplot.tabs.plugins import get_plugins_cached 20 | from xiplot.utils.io import FinallyCloseBytesIO 21 | 22 | 23 | def get_data_filepaths(data_dir=""): 24 | try: 25 | return sorted( 26 | (fp for fp in Path(data_dir).iterdir() if fp.is_file()), 27 | reverse=True, 28 | ) 29 | 30 | except FileNotFoundError: 31 | return [] 32 | 33 | 34 | def read_functions() -> ( 35 | Iterator[Tuple[Callable[[BytesIO], pd.DataFrame], str]] 36 | ): 37 | """Generate all functions for reading to a dataframe. 38 | 39 | Yields: 40 | fn: Function that reads the data and returns a dataframe. 41 | ext: File extension that the readed can handle. 42 | """ 43 | for _, _, plugin in get_plugins_cached("read"): 44 | plugin = plugin() 45 | if plugin is not None: 46 | yield plugin 47 | 48 | def read_json(data): 49 | if isinstance(data, BytesIO): 50 | data = data.getvalue().decode("utf-8") 51 | elif isinstance(data, tarfile.ExFileObject): 52 | data = data.read().decode("utf-8") 53 | 54 | try: 55 | return pd.read_json(data, typ="frame", orient="columns") 56 | except Exception: 57 | return pd.read_json(data, typ="frame", orient="split") 58 | 59 | yield pd.read_csv, ".csv" 60 | yield read_json, ".json" 61 | 62 | 63 | def write_functions() -> ( 64 | Iterator[Tuple[Callable[[pd.DataFrame, BytesIO], None], str, str]] 65 | ): 66 | """Generate all functions for writing dataframes. 67 | 68 | Yields: 69 | fn: Function that writes the dataframe to bytes. 70 | ext: File extension that matches the written data. 71 | mime: MIME type of the written data. 72 | """ 73 | for _, _, plugin in get_plugins_cached("write"): 74 | plugin = plugin() 75 | if plugin is not None: 76 | yield plugin 77 | 78 | yield lambda df, file: df.to_csv(file, index=False), ".csv", "text/csv" 79 | yield lambda df, file: df.to_json( 80 | file, orient="split", index=False 81 | ), ".json", "application/json" 82 | 83 | 84 | def read_dataframe_with_extension(data, filename=None): 85 | """ 86 | Read the given data and convert it to a pandas data frame 87 | 88 | Parameters: 89 | 90 | data: File name or File-like object 91 | filename: File name as a string 92 | 93 | Returns: 94 | 95 | df: Pandas data frame 96 | aux: Pandas data frame 97 | meta: dictionary of metadata 98 | """ 99 | if filename is None: 100 | filename = data 101 | 102 | if ".tar" in Path(filename).suffixes: 103 | stem = Path(filename).name[: -len("".join(Path(filename).suffixes))] 104 | 105 | with ( 106 | tarfile.open(name=data) 107 | if isinstance(data, (str, Path)) 108 | else tarfile.open(fileobj=data) 109 | ) as tar: 110 | df_file = None 111 | df_name = None 112 | aux_file = None 113 | aux_name = None 114 | meta_file = None 115 | 116 | for member in tar.getmembers(): 117 | if not member.isfile(): 118 | raise Exception( 119 | f"Tar contains a non-file entry {member.name}" 120 | ) 121 | 122 | if Path(member.name).stem == "data": 123 | if df_file is not None: 124 | raise Exception("Tar contains more than one data file") 125 | df_file = tar.extractfile(member) 126 | df_name = Path(stem).with_suffix(Path(member.name).suffix) 127 | elif Path(member.name).stem == "aux": 128 | if aux_file is not None: 129 | raise Exception( 130 | "Tar contains more than one auxiliary file" 131 | ) 132 | aux_file = tar.extractfile(member) 133 | aux_name = member.name 134 | elif member.name == "meta.json": 135 | if meta_file is not None: 136 | raise Exception("Tar contains more than metadata file") 137 | meta_file = tar.extractfile(member) 138 | else: 139 | raise Exception( 140 | f"Tar contains extraneous file '{member.name}'" 141 | ) 142 | 143 | if df_file is None: 144 | raise Exception("Tar contains no data file called 'data.*'") 145 | 146 | if aux_file is None: 147 | raise Exception( 148 | "Tar contains no auxiliary file called 'aux.*'" 149 | ) 150 | 151 | if meta_file is None: 152 | raise Exception( 153 | "Tar contains no metadata file called 'meta.json'" 154 | ) 155 | 156 | metadata = ( 157 | json.load(meta_file, object_pairs_hook=OrderedDict) 158 | or OrderedDict() 159 | ) 160 | metadata["filename"] = str(df_name) 161 | 162 | df = read_only_dataframe(df_file, df_name) 163 | 164 | try: 165 | aux = read_only_dataframe(aux_file, aux_name) 166 | except pd.errors.EmptyDataError: 167 | aux = pd.DataFrame() 168 | 169 | if aux.empty: 170 | aux.index = df.index 171 | if df.shape[0] != aux.shape[0]: 172 | raise Exception( 173 | "The dataframe and auxiliary data have different number of" 174 | " rows." 175 | ) 176 | 177 | return df, aux, metadata 178 | 179 | df = read_only_dataframe(data, filename) 180 | return ( 181 | df, 182 | pd.DataFrame(index=df.index), 183 | OrderedDict(filename=str(filename)), 184 | ) 185 | 186 | 187 | def read_only_dataframe(data, filename): 188 | file_extension = Path(filename).suffix 189 | error = None 190 | 191 | for fn, ext in read_functions(): 192 | if file_extension == ext: 193 | try: 194 | return fn(data) 195 | except Exception as e: 196 | error = e 197 | 198 | if error is not None: 199 | raise error 200 | raise Exception(f"Unsupported dataframe format '{file_extension}'") 201 | 202 | 203 | def write_dataframe_and_metadata( 204 | df: pd.DataFrame, 205 | aux: pd.DataFrame, 206 | meta: Dict[str, Any], 207 | filepath: str, 208 | file, 209 | file_extension: Optional[str] = None, 210 | ) -> Tuple[str, str]: 211 | if not aux.empty and df.shape[0] != aux.shape[0]: 212 | raise Exception( 213 | "The dataframe and auxiliary data have different number of rows." 214 | ) 215 | if file_extension is None: 216 | file_extension = Path(filepath).suffix 217 | 218 | with tarfile.open(fileobj=file, mode="w:gz") as tar: 219 | df_file = Path("data").with_suffix(file_extension).name 220 | aux_file = Path("aux").with_suffix(file_extension).name 221 | meta_file = "meta.json" 222 | tar_file = Path(filepath).with_suffix(".tar.gz").name 223 | 224 | with FinallyCloseBytesIO() as df_bytes: 225 | write_only_dataframe(df, filepath, df_bytes, file_extension) 226 | df_bytes = df_bytes.getvalue() 227 | 228 | df_info = tarfile.TarInfo(df_file) 229 | df_info.size = len(df_bytes) 230 | 231 | tar.addfile(df_info, BytesIO(df_bytes)) 232 | 233 | with FinallyCloseBytesIO() as aux_bytes: 234 | write_only_dataframe(aux, aux_file, aux_bytes, file_extension) 235 | aux_bytes = aux_bytes.getvalue() 236 | 237 | aux_info = tarfile.TarInfo(aux_file) 238 | aux_info.size = len(aux_bytes) 239 | 240 | tar.addfile(aux_info, BytesIO(aux_bytes)) 241 | 242 | meta = meta or OrderedDict() 243 | meta["filename"] = Path(filepath).name 244 | 245 | meta_string = json.dumps(meta) 246 | meta_bytes = meta_string.encode("utf-8") 247 | 248 | meta_info = tarfile.TarInfo(meta_file) 249 | meta_info.size = len(meta_bytes) 250 | 251 | tar.addfile(meta_info, BytesIO(meta_bytes)) 252 | 253 | return tar_file, "application/gzip" 254 | 255 | 256 | def write_only_dataframe( 257 | df: pd.DataFrame, 258 | filepath: str, 259 | file: BytesIO, 260 | file_extension: Optional[str] = None, 261 | ) -> Tuple[str, str]: 262 | if file_extension is None: 263 | file_name = Path(filepath).name 264 | file_extension = Path(filepath).suffix 265 | else: 266 | file_name = Path(filepath).with_suffix(file_extension).name 267 | error = None 268 | 269 | for fn, ext, mime in write_functions(): 270 | if file_extension == ext: 271 | try: 272 | fn(df, file) 273 | return file_name, mime 274 | except Exception as e: 275 | error = e 276 | 277 | if error is not None: 278 | raise error 279 | raise Exception(f"Unsupported dataframe format '{file_extension}'") 280 | 281 | 282 | def get_numeric_columns(df, columns=None): 283 | """ 284 | Return only columns, which are numeric 285 | 286 | Parameters: 287 | df: pandas.DataFrame 288 | columns: columns of the data frame 289 | 290 | Returns: 291 | columns: numeric columns 292 | """ 293 | if columns is not None: 294 | df = df[columns] 295 | return df.select_dtypes("number").columns.to_list() 296 | 297 | 298 | def get_default_column( 299 | columns: List[str], axis: Literal["x", "y"] 300 | ) -> Optional[str]: 301 | """Get default values for x_axis and y_axis from a list of column names. 302 | 303 | Args: 304 | columns: List of column names. 305 | axis: Is this the "x" or "y" axis. 306 | 307 | Returns: 308 | Column name. 309 | """ 310 | if len(columns) == 0: 311 | return None 312 | if axis == "x": 313 | for c in columns: 314 | if "x" in c or "1" in c or "X" in c: 315 | return c 316 | return columns[0] 317 | 318 | else: 319 | for c in columns: 320 | if "y" in c or "2" in c or "Y" in c: 321 | return c 322 | return columns[min(1, len(columns) - 1)] 323 | -------------------------------------------------------------------------------- /xiplot/utils/io.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | 4 | class FinallyCloseBytesIO: 5 | def __init__(self): 6 | self.bytes = NoCloseBytesIO() 7 | 8 | def __enter__(self): 9 | return self.bytes 10 | 11 | def __exit__(self, exc_type, exc_value, traceback): 12 | super(NoCloseBytesIO, self.bytes).close() 13 | 14 | 15 | class NoCloseBytesIO(BytesIO): 16 | def close(self): 17 | pass 18 | -------------------------------------------------------------------------------- /xiplot/utils/layouts.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | 4 | def layout_wrapper(component, id="", style=None, css_class=None, title=None): 5 | """ 6 | Wraps a dash component to a html.Div element 7 | 8 | Parameters: 9 | 10 | component: dash core component 11 | id: id of the html.Div element 12 | style: style of the html.Div element 13 | css_class: className of the html.Div 14 | title: title of the dash core component 15 | 16 | Returns: 17 | 18 | layout: html.Div element 19 | """ 20 | layout = html.Div( 21 | children=[html.Div(title), component], 22 | id=id, 23 | style=style, 24 | className=css_class, 25 | ) 26 | return layout 27 | -------------------------------------------------------------------------------- /xiplot/utils/regex.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional, Tuple 3 | 4 | 5 | def dropdown_regex( 6 | options: List[str], selected: List[str], new_regex: Optional[str] = None 7 | ) -> Tuple[List[str], List[str], int]: 8 | """Dropdown options with regex. 9 | 10 | Args: 11 | options: List of dropdown options. 12 | selected: List of dropdown selection. 13 | new_regex: Regex to add to the selection. Defaults to None. 14 | 15 | Returns: 16 | new_options: New list of options (reduced by regexes). 17 | new_selected: New list of selection (including the new regex). 18 | hits: number of hits for the new regex or zero. 19 | """ 20 | if selected is None: 21 | selected = [] 22 | hits = False 23 | # Add optional new regex 24 | if new_regex: 25 | new_regex = new_regex + " (regex)" 26 | if new_regex not in selected: 27 | selected = selected + [new_regex] 28 | hits = True 29 | # Remove selected from options 30 | options = [o for o in options if o not in selected] 31 | old_len = len(options) 32 | # Remove regex matches from options 33 | is_regex = re.compile(r" \(regex(()|(: \d+))\)$") 34 | select_results = [] 35 | for s in selected: 36 | reg = is_regex.search(s) 37 | if reg: 38 | s = s[: reg.start()] 39 | regex = re.compile(s) 40 | old_len = len(options) 41 | options = [o for o in options if not regex.search(o)] 42 | s = f"{s} (regex: {old_len - len(options)})" 43 | select_results.append(s) 44 | # Return new_options, new_selected, hits for the new regex 45 | return ( 46 | select_results + options, 47 | select_results, 48 | old_len - len(options) if hits else 0, 49 | ) 50 | 51 | 52 | def get_columns_by_regex(columns, features): 53 | if features is None: 54 | return [] 55 | 56 | new_features = [] 57 | 58 | is_regex = re.compile(r" \(regex(()|(: \d+))\)$") 59 | for f in features: 60 | reg = is_regex.search(f) 61 | if reg: 62 | regex = re.compile(f[: reg.start()]) 63 | for c in columns: 64 | if regex.search(c) and c not in new_features: 65 | new_features.append(c) 66 | elif f not in new_features: 67 | new_features.append(f) 68 | return new_features 69 | 70 | 71 | if __name__ == "__main__": 72 | print( 73 | dropdown_regex( 74 | [ 75 | "PCA 1", 76 | "PCA 2", 77 | "mpg", 78 | "cylinders", 79 | "displacement", 80 | "horsepower", 81 | "weight", 82 | "acceleration", 83 | "model-year", 84 | "origin", 85 | ], 86 | ["mpg", "weight", "P.* (regex)"], 87 | ) 88 | ) 89 | -------------------------------------------------------------------------------- /xiplot/utils/scatterplot.py: -------------------------------------------------------------------------------- 1 | def get_row(points): 2 | row = None 3 | for p in points: 4 | if p: 5 | row = p["points"][0]["customdata"][0]["index"] 6 | 7 | return row 8 | -------------------------------------------------------------------------------- /xiplot/utils/store.py: -------------------------------------------------------------------------------- 1 | class ServerSideStoreBackend: 2 | def __init__(self): 3 | self.store = dict() 4 | 5 | def get(self, key, ignore_expired=False): 6 | return self.store.get(key) 7 | 8 | def set(self, key, value): 9 | self.store[key] = value 10 | 11 | def has(self, key): 12 | return key in self.store 13 | -------------------------------------------------------------------------------- /xiplot/utils/table.py: -------------------------------------------------------------------------------- 1 | def get_sort_by(sort_by, selected_rows, trigger): 2 | all_selected = all(selected_rows) or not any(selected_rows) 3 | if len(sort_by) == 0 and all_selected: 4 | sort_by = [] 5 | 6 | elif len(sort_by) == 0 and not all_selected: 7 | sort_by = [ 8 | {"column_id": "Selection", "direction": "desc"}, 9 | {"column_id": "index_copy", "direction": "asc"}, 10 | ] 11 | 12 | elif len(sort_by) != 0 and True not in selected_rows: 13 | pass 14 | 15 | elif sort_by[0]["column_id"] != "Selection": 16 | sort_by.insert(0, {"column_id": "Selection", "direction": "desc"}) 17 | 18 | elif ( 19 | trigger != "auxiliary_store" 20 | and {"column_id": "index_copy", "direction": "asc"} in sort_by 21 | ): 22 | sort_by.remove({"column_id": "index_copy", "direction": "asc"}) 23 | sort_by.append({"column_id": "index_copy", "direction": "asc"}) 24 | 25 | elif ( 26 | all_selected 27 | and {"column_id": "index_copy", "direction": "desc"} in sort_by 28 | ): 29 | sort_by.remove({"column_id": "Selection", "direction": "desc"}) 30 | sort_by.remove({"column_id": "index_copy", "direction": "asc"}) 31 | 32 | elif sort_by == {"column_id": "Selection", "direction": "desc"}: 33 | sort_by.append({"column_id": "index_copy", "direction": "asc"}) 34 | 35 | elif len(sort_by) == 1 and sort_by[0]["column_id"] == "Selection": 36 | sort_by.append({"column_id": "index_copy", "direction": "asc"}) 37 | 38 | return sort_by 39 | 40 | 41 | def get_updated_item(items, index, inputs_list): 42 | """ 43 | Return the of the item that has been updated among all items except a new 44 | item entry 45 | 46 | Parameters: 47 | items: list of items (Input ALL gets list of all items that has same 48 | id type) 49 | index: index of the item that has been updated 50 | inputs_list: list of all inputs of the callback 51 | 52 | Returns: 53 | item: item that has been updated 54 | """ 55 | # Ignore a new item 56 | if items[-1] is None: 57 | items = items[:-1] 58 | 59 | # Convert all None values to an empty list 60 | for id, item in enumerate(items): 61 | if item is None: 62 | items[id] = [] 63 | 64 | # Get the updated item 65 | item_id = get_updated_item_id(items, index, inputs_list) 66 | item = items[item_id] 67 | 68 | return item 69 | 70 | 71 | def get_updated_item_id(items, index, inputs_list): 72 | for id, item in enumerate(inputs_list): 73 | if item["id"]["index"] == index: 74 | return id 75 | --------------------------------------------------------------------------------