├── tests ├── __init__.py ├── scripts │ └── test_cli.py └── test_treeviz.py ├── src └── phytreeviz │ ├── scripts │ ├── __init__.py │ └── cli.py │ ├── utils │ ├── example_data │ │ ├── small_example.nwk │ │ ├── medium_example.nwk │ │ └── large_example.nwk │ └── __init__.py │ ├── __init__.py │ └── treeviz.py ├── docs ├── api-docs │ └── treeviz.md ├── images │ ├── api_example01.png │ ├── api_example02.png │ ├── api_example03.png │ ├── api_example04.png │ ├── cli_example01.png │ ├── cli_example02.png │ └── cli_example03.png ├── index.md └── cli-docs │ └── phytreeviz.md ├── example ├── example.zip ├── small_example.nwk ├── medium_example.nwk └── large_example.nwk ├── CITATION.cff ├── .github └── workflows │ ├── publich_mkdocs.yml │ ├── publish_to_pypi.yml │ └── ci.yml ├── LICENSE ├── mkdocs.yml ├── pyproject.toml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/phytreeviz/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/api-docs/treeviz.md: -------------------------------------------------------------------------------- 1 | # TreeViz Class 2 | 3 | ::: phytreeviz.treeviz.TreeViz 4 | -------------------------------------------------------------------------------- /example/example.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/example/example.zip -------------------------------------------------------------------------------- /docs/images/api_example01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/api_example01.png -------------------------------------------------------------------------------- /docs/images/api_example02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/api_example02.png -------------------------------------------------------------------------------- /docs/images/api_example03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/api_example03.png -------------------------------------------------------------------------------- /docs/images/api_example04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/api_example04.png -------------------------------------------------------------------------------- /docs/images/cli_example01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/cli_example01.png -------------------------------------------------------------------------------- /docs/images/cli_example02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/cli_example02.png -------------------------------------------------------------------------------- /docs/images/cli_example03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moshi4/phyTreeViz/HEAD/docs/images/cli_example03.png -------------------------------------------------------------------------------- /example/small_example.nwk: -------------------------------------------------------------------------------- 1 | ((Hylobates_moloch:0.333,Nomascus_leucogenys:0.3123)1.00:0.6897,(Pongo_abelii:0.8478,(Gorilla_gorilla_gorilla:0.4021,(Homo_sapiens:0.3164,(Pan_troglodytes:0.1144,Pan_paniscus:0.1106)0.97:0.1865)0.99:0.1052)1.00:0.3929)1.00:0.114)1.00; 2 | -------------------------------------------------------------------------------- /src/phytreeviz/utils/example_data/small_example.nwk: -------------------------------------------------------------------------------- 1 | ((Hylobates_moloch:0.333,Nomascus_leucogenys:0.3123)1.00:0.6897,(Pongo_abelii:0.8478,(Gorilla_gorilla_gorilla:0.4021,(Homo_sapiens:0.3164,(Pan_troglodytes:0.1144,Pan_paniscus:0.1106)0.97:0.1865)0.99:0.1052)1.00:0.3929)1.00:0.114)1.00; 2 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: If you use this software, please cite it as below. 3 | authors: 4 | - family-names: Shimoyama 5 | given-names: Yuki 6 | title: "phyTreeViz: Simple phylogenetic tree visualization python package" 7 | date-released: 2023-09-08 8 | url: https://github.com/moshi4/phyTreeViz 9 | -------------------------------------------------------------------------------- /src/phytreeviz/__init__.py: -------------------------------------------------------------------------------- 1 | import matplotlib as mpl 2 | 3 | from phytreeviz.treeviz import TreeViz 4 | from phytreeviz.utils import load_example_tree_file 5 | 6 | __version__ = "0.2.0" 7 | 8 | __all__ = [ 9 | "TreeViz", 10 | "load_example_tree_file", 11 | ] 12 | 13 | # Setting matplotlib rc(runtime configuration) parameters 14 | # https://matplotlib.org/stable/tutorials/introductory/customizing.html 15 | mpl_rc_params = { 16 | # SVG 17 | "svg.fonttype": "none", 18 | "savefig.bbox": "tight", 19 | } 20 | mpl.rcParams.update(mpl_rc_params) 21 | -------------------------------------------------------------------------------- /.github/workflows/publich_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish MkDocs 2 | 3 | on: 4 | release: 5 | types: [released] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish_mkdocs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup Python 3.10 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Install MkDocs & Plugins 21 | run: | 22 | pip install . 23 | pip install mkdocs mkdocs-material mkdocs-jupyter mkdocstrings[python] black 24 | 25 | - name: Publish document 26 | run: mkdocs gh-deploy --force 27 | -------------------------------------------------------------------------------- /example/medium_example.nwk: -------------------------------------------------------------------------------- 1 | (((Hylobates_moloch:0.00333,Nomascus_leucogenys:0.003123):0.006897,(Pongo_abelii:0.008478,(Gorilla_gorilla_gorilla:0.004021,(Homo_sapiens:0.003164,(Pan_troglodytes:0.001144,Pan_paniscus:0.001106):0.001865):0.001052):0.003929):0.00114):0.004949,(((Colobus_angolensis_palliatus:0.005275,Piliocolobus_tephrosceles:0.004777):0.001021,(Trachypithecus_francoisi:0.004061,(Rhinopithecus_roxellana:0.000994,Rhinopithecus_bieti:0.002571):0.00248):0.001915):0.002828,(Chlorocebus_sabaeus:0.005852,((Macaca_nemestrina:0.002166,(Macaca_thibetana_thibetana:0.001527,(Macaca_fascicularis:0.001199,Macaca_mulatta:0.001166):0.000396):3.7e-05):0.001884,((Mandrillus_leucophaeus:0.00342,Cercocebus_atys:0.003303):0.000458,(Papio_anubis:0.002032,Theropithecus_gelada:0.001939):0.000891):0.00056):0.00146):0.00244):0.009713); 2 | -------------------------------------------------------------------------------- /src/phytreeviz/utils/example_data/medium_example.nwk: -------------------------------------------------------------------------------- 1 | (((Hylobates_moloch:0.00333,Nomascus_leucogenys:0.003123):0.006897,(Pongo_abelii:0.008478,(Gorilla_gorilla_gorilla:0.004021,(Homo_sapiens:0.003164,(Pan_troglodytes:0.001144,Pan_paniscus:0.001106):0.001865):0.001052):0.003929):0.00114):0.004949,(((Colobus_angolensis_palliatus:0.005275,Piliocolobus_tephrosceles:0.004777):0.001021,(Trachypithecus_francoisi:0.004061,(Rhinopithecus_roxellana:0.000994,Rhinopithecus_bieti:0.002571):0.00248):0.001915):0.002828,(Chlorocebus_sabaeus:0.005852,((Macaca_nemestrina:0.002166,(Macaca_thibetana_thibetana:0.001527,(Macaca_fascicularis:0.001199,Macaca_mulatta:0.001166):0.000396):3.7e-05):0.001884,((Mandrillus_leucophaeus:0.00342,Cercocebus_atys:0.003303):0.000458,(Papio_anubis:0.002032,Theropithecus_gelada:0.001939):0.000891):0.00056):0.00146):0.00244):0.009713); 2 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | release: 4 | types: [released] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | publish_to_pypi: 9 | name: Publish to PyPI 10 | runs-on: ubuntu-latest 11 | env: 12 | PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} 13 | PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Python 3.9 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.9 22 | 23 | - name: Install Poetry 24 | run: | 25 | curl -sSL https://install.python-poetry.org | python3 - 26 | echo "$HOME/.local/bin" >> $GITHUB_PATH 27 | 28 | - name: Build 29 | run: poetry build 30 | 31 | - name: Publish 32 | run: poetry publish -u $PYPI_USERNAME -p $PYPI_PASSWORD 33 | -------------------------------------------------------------------------------- /src/phytreeviz/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | 6 | def load_example_tree_file(filename: str) -> Path: 7 | """Load example phylogenetic tree file 8 | 9 | List of example tree filename 10 | 11 | - `small_example.nwk` (7 species) 12 | - `medium_example.nwk` (21 species) 13 | - `large_example.nwk` (190 species) 14 | 15 | Parameters 16 | ---------- 17 | filename : str 18 | Target filename 19 | 20 | Returns 21 | ------- 22 | tree_file : Path 23 | Tree file (Newick format) 24 | """ 25 | example_data_dir = Path(__file__).parent / "example_data" 26 | example_files = example_data_dir.glob("*.nwk") 27 | available_filenames = [f.name for f in example_files] 28 | if filename not in available_filenames: 29 | raise ValueError(f"{filename=} is invalid.\n{available_filenames=})") 30 | target_file = example_data_dir / filename 31 | return target_file 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 moshi 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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main, develop] 5 | paths: ["src/**", "tests/**", ".github/workflows/ci.yml"] 6 | pull_request: 7 | branches: [main, develop] 8 | paths: ["src/**", "tests/**", ".github/workflows/ci.yml"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | CI: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Setup Python ${{ matrix.python-version}} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: pip install -e . pytest pytest-cov ruff 29 | 30 | - name: Run ruff format check 31 | run: ruff format --check --diff 32 | 33 | - name: Run ruff lint check 34 | run: ruff check --diff 35 | 36 | - name: Run pytest 37 | run: pytest tests --tb=line --cov=src --cov-report=xml --cov-report=term 38 | -------------------------------------------------------------------------------- /tests/scripts/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess as sp 4 | from pathlib import Path 5 | 6 | from phytreeviz import load_example_tree_file 7 | 8 | 9 | def test_phytreeviz_cli1(tmp_path: Path): 10 | """Test phyTreeViz CLI""" 11 | outfile = tmp_path / "result.png" 12 | 13 | cmd = f"phytreeviz -i '((A,B),((C,D),(E,(F,G))));' -o {outfile}" 14 | sp.run(cmd, shell=True) 15 | 16 | assert outfile.exists() 17 | 18 | 19 | def test_phytreeviz_cli2(tmp_path: Path): 20 | """Test phyTreeViz CLI""" 21 | outfile = tmp_path / "result.png" 22 | 23 | tree_file = load_example_tree_file("small_example.nwk") 24 | cmd = f"phytreeviz -i {tree_file} -o {outfile} " 25 | cmd += "--show_branch_length --show_confidence " 26 | sp.run(cmd, shell=True) 27 | 28 | assert outfile.exists() 29 | 30 | 31 | def test_phytreeviz_cli3(tmp_path: Path): 32 | """Test phyTreeViz CLI""" 33 | outfile = tmp_path / "result.png" 34 | 35 | tree_file = load_example_tree_file("medium_example.nwk") 36 | cmd = f"phytreeviz -i {tree_file} -o {outfile} " 37 | cmd += "--fig_height 0.3 --align_leaf_label " 38 | sp.run(cmd, shell=True) 39 | 40 | assert outfile.exists() 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # phyTreeViz 2 | 3 | ![Python3](https://img.shields.io/badge/Language-Python3-steelblue) 4 | ![OS](https://img.shields.io/badge/OS-_Windows_|_Mac_|_Linux-steelblue) 5 | ![License](https://img.shields.io/badge/License-MIT-steelblue) 6 | [![Latest PyPI version](https://img.shields.io/pypi/v/phytreeviz.svg)](https://pypi.python.org/pypi/phytreeviz) 7 | [![conda-forge](https://img.shields.io/conda/vn/conda-forge/phytreeviz.svg?color=green)](https://anaconda.org/conda-forge/phytreeviz) 8 | 9 | ## Overview 10 | 11 | phyTreeViz is a simple and minimal phylogenetic tree visualization python package implemented based on matplotlib. 12 | This package was developed to enhance phylogenetic tree visualization functionality of BioPython. 13 | 14 | phyTreeViz is intended to provide a simple and easy-to-use phylogenetic tree visualization function without complexity. 15 | Therefore, if you need complex tree annotations, I recommend using [ete](https://github.com/etetoolkit/ete) or [ggtree](https://github.com/YuLab-SMU/ggtree). 16 | 17 | ## Installation 18 | 19 | `Python 3.8 or later` is required for installation. 20 | 21 | **Install PyPI package:** 22 | 23 | pip install phytreeviz 24 | 25 | **Install conda-forge package:** 26 | 27 | conda install -c conda-forge phytreeviz 28 | 29 | ## Examples 30 | 31 | ![example01.png](./images/api_example01.png) 32 | ___ 33 | ![example02.png](./images/api_example02.png) 34 | ___ 35 | ![example03.png](./images/api_example03.png) 36 | ___ 37 | ![example04.png](./images/api_example04.png) 38 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: phyTreeViz 2 | site_description: Simple phylogenetic tree visualization python package 3 | site_author: moshi4 4 | repo_name: moshi4/phyTreeViz 5 | repo_url: https://github.com/moshi4/phyTreeViz 6 | edit_uri: "" 7 | use_directory_urls: true 8 | watch: 9 | - src 10 | 11 | nav: 12 | - Home: index.md 13 | - Getting Started: getting_started.ipynb 14 | - Plot API Example: plot_api_example.ipynb 15 | - API Docs: api-docs/treeviz.md 16 | - CLI Docs: cli-docs/phytreeviz.md 17 | 18 | theme: 19 | name: material # material, readthedocs, mkdocs 20 | features: 21 | - navigation.top 22 | - navigation.expand 23 | # - navigation.tabs 24 | - navigation.tabs.sticky 25 | - navigation.sections 26 | 27 | markdown_extensions: 28 | - pymdownx.highlight: 29 | anchor_linenums: true 30 | - pymdownx.inlinehilite 31 | - pymdownx.snippets 32 | - pymdownx.superfences 33 | - pymdownx.details 34 | - admonition 35 | - attr_list 36 | - md_in_html 37 | 38 | plugins: 39 | - search 40 | - mkdocs-jupyter: 41 | execute: False 42 | - mkdocstrings: 43 | handlers: 44 | python: 45 | # Reference: https://mkdocstrings.github.io/python/usage/ 46 | options: 47 | # Heading options 48 | heading_level: 2 49 | show_root_full_path: False 50 | show_root_heading: True 51 | # Member options 52 | members_order: source # alphabetical, source 53 | # Docstrings options 54 | docstring_style: numpy 55 | docstring_section_style: spacy # table, list, spacy 56 | line_length: 89 57 | merge_init_into_class: True 58 | # Signatures/annotations options 59 | show_signature_annotations: True 60 | separate_signature: True 61 | # Additional options 62 | show_source: False 63 | -------------------------------------------------------------------------------- /docs/cli-docs/phytreeviz.md: -------------------------------------------------------------------------------- 1 | # phyTreeViz CLI Document 2 | 3 | ## Usage 4 | 5 | ### Basic Command 6 | 7 | phytreeviz -i [Tree file or text] -o [Tree visualization file] 8 | 9 | ### Options 10 | 11 | General Options: 12 | -i IN, --intree IN Input phylogenetic tree file or text 13 | -o OUT, --outfile OUT Output phylogenetic tree plot file [*.png|*.jpg|*.svg|*.pdf] 14 | --format Input phylogenetic tree format (Default: 'newick') 15 | -v, --version Print version information 16 | -h, --help Show this help message and exit 17 | 18 | Figure Appearence Options: 19 | --fig_height Figure height per leaf node of tree (Default: 0.5) 20 | --fig_width Figure width (Default: 8.0) 21 | --leaf_label_size Leaf label size (Default: 12) 22 | --ignore_branch_length Ignore branch length for plotting tree (Default: OFF) 23 | --align_leaf_label Align leaf label position (Default: OFF) 24 | --show_branch_length Show branch length (Default: OFF) 25 | --show_confidence Show confidence (Default: OFF) 26 | --dpi Figure DPI (Default: 300) 27 | 28 | Available Tree Format: ['newick', 'phyloxml', 'nexus', 'nexml', 'cdao'] 29 | 30 | ### Example Command 31 | 32 | Click [here](https://github.com/moshi4/phyTreeViz/raw/main/example/example.zip) to download example tree files. 33 | 34 | #### Example 1 35 | 36 | phytreeviz -i "((A,B),((C,D),(E,(F,G))));" -o cli_example01.png 37 | 38 | ![example01.png](../images/cli_example01.png) 39 | 40 | #### Example 2 41 | 42 | phytreeviz -i ./example/small_example.nwk -o cli_example02.png \ 43 | --show_branch_length --show_confidence 44 | 45 | ![example02.png](../images/cli_example02.png) 46 | 47 | #### Example 3 48 | 49 | phytreeviz -i ./example/medium_example.nwk -o cli_example03.png \ 50 | --fig_height 0.3 --align_leaf_label 51 | 52 | ![example03.png](../images/cli_example03.png) 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "phytreeviz" 3 | version = "0.2.0" 4 | description = "Simple phylogenetic tree visualization python package" 5 | authors = ["moshi4"] 6 | license = "MIT" 7 | repository = "https://github.com/moshi4/phyTreeViz/" 8 | readme = "README.md" 9 | keywords = [ 10 | "bioinformatics", 11 | "phylogenetics", 12 | "visualization", 13 | "matplotlib", 14 | "phylogenetic-tree", 15 | ] 16 | classifiers = [ 17 | "Intended Audience :: Science/Research", 18 | "Topic :: Scientific/Engineering :: Bio-Informatics", 19 | "Framework :: Matplotlib", 20 | ] 21 | include = ["tests"] 22 | packages = [{ include = "phytreeviz", from = "src" }] 23 | 24 | [tool.poetry.scripts] 25 | phytreeviz = "phytreeviz.scripts:cli.main" 26 | 27 | [tool.pytest.ini_options] 28 | minversion = "6.0" 29 | addopts = "--cov=src --tb=line --cov-report=xml --cov-report=term" 30 | testpaths = ["tests"] 31 | 32 | [tool.ruff] 33 | src = ["src", "tests"] 34 | line-length = 88 35 | 36 | # Lint Rules: https://docs.astral.sh/ruff/rules/ 37 | [tool.ruff.lint] 38 | select = [ 39 | "F", # pyflakes 40 | "E", # pycodestyle (Error) 41 | "W", # pycodestyle (Warning) 42 | "I", # isort 43 | "D", # pydocstyle 44 | ] 45 | ignore = [ 46 | "D100", # Missing docstring in public module 47 | "D101", # Missing docstring in public class 48 | "D104", # Missing docstring in public package 49 | "D105", # Missing docstring in magic method 50 | "D205", # 1 blank line required between summary line and description 51 | "D400", # First line should end with a period 52 | "D401", # First line should be in imperative mood 53 | "D403", # First word of the first line should be properly capitalized 54 | "D415", # First line should end with a period, question mark, or exclamation point 55 | ] 56 | 57 | [tool.ruff.pydocstyle] 58 | convention = "numpy" 59 | 60 | [tool.poetry.dependencies] 61 | python = "^3.8" 62 | matplotlib = ">=3.5.3" 63 | biopython = ">=1.7.9" 64 | numpy = ">=1.21.1" 65 | 66 | [tool.poetry.group.dev.dependencies] 67 | ruff = ">=0.1.6" 68 | pytest = ">=7.1.2" 69 | pytest-cov = ">=4.0.0" 70 | ipykernel = ">=6.13.0" 71 | 72 | [tool.poetry.group.docs.dependencies] 73 | mkdocs = ">=1.2" 74 | mkdocstrings = { extras = ["python"], version = ">=0.19.0" } 75 | mkdocs-jupyter = ">=0.21.0" 76 | mkdocs-material = ">=8.2" 77 | black = ">=22.10.0" 78 | 79 | [build-system] 80 | requires = ["poetry-core"] 81 | build-backend = "poetry.core.masonry.api" 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | example/ 2 | examples/ 3 | .vscode/ 4 | notebooks/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /tests/test_treeviz.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from matplotlib.patches import Patch 6 | 7 | from phytreeviz import TreeViz, load_example_tree_file 8 | 9 | 10 | def test_treeviz1(tmp_path: Path): 11 | """Test TreeViz""" 12 | outfile = tmp_path / "result.png" 13 | 14 | tree_file = load_example_tree_file("small_example.nwk") 15 | 16 | tv = TreeViz(tree_file) 17 | tv.show_branch_length(color="red") 18 | tv.show_confidence(color="blue") 19 | tv.show_scale_bar() 20 | 21 | tv.savefig(outfile, dpi=300) 22 | assert outfile.exists() 23 | 24 | 25 | def test_treeviz2(tmp_path: Path): 26 | """Test TreeViz""" 27 | outfile = tmp_path / "result.png" 28 | 29 | tree_file = load_example_tree_file("small_example.nwk") 30 | 31 | tv = TreeViz(tree_file, height=0.7) 32 | tv.show_scale_axis() 33 | 34 | tv.set_node_label_props("Homo_sapiens", color="grey") 35 | tv.set_node_label_props("Pongo_abelii", color="green", style="italic") 36 | 37 | tv.set_node_line_props( 38 | ["Hylobates_moloch", "Nomascus_leucogenys"], color="orange", lw=2 39 | ) 40 | tv.set_node_line_props( 41 | ["Homo_sapiens", "Pan_troglodytes", "Pan_paniscus"], 42 | color="magenta", 43 | ls="dotted", 44 | ) 45 | 46 | tv.savefig(outfile, dpi=300) 47 | assert outfile.exists() 48 | 49 | 50 | def test_treeviz3(tmp_path: Path): 51 | """Test TreeViz""" 52 | outfile = tmp_path / "result.png" 53 | 54 | tree_file = load_example_tree_file("small_example.nwk") 55 | 56 | tv = TreeViz(tree_file, align_leaf_label=True) 57 | tv.show_scale_axis() 58 | 59 | group1 = ["Hylobates_moloch", "Nomascus_leucogenys"] 60 | group2 = ["Homo_sapiens", "Pan_paniscus"] 61 | 62 | tv.highlight(group1, "orange") 63 | tv.highlight(group2, "lime") 64 | 65 | tv.annotate(group1, "group1") 66 | tv.annotate(group2, "group2") 67 | 68 | tv.marker(group1, marker="s", color="blue") 69 | tv.marker(group2, marker="D", color="purple", descendent=True) 70 | tv.marker("Pongo_abelii", color="red") 71 | 72 | tv.savefig(outfile, dpi=300) 73 | assert outfile.exists() 74 | 75 | 76 | def test_treeviz4(tmp_path: Path): 77 | """Test TreeViz""" 78 | outfile = tmp_path / "result.png" 79 | 80 | tree_file = load_example_tree_file("medium_example.nwk") 81 | 82 | tv = TreeViz(tree_file, height=0.3, align_leaf_label=True, leaf_label_size=10) 83 | tv.show_scale_bar() 84 | 85 | group1 = ["Hylobates_moloch", "Nomascus_leucogenys"] 86 | group2 = ["Homo_sapiens", "Pongo_abelii"] 87 | group3 = ["Piliocolobus_tephrosceles", "Rhinopithecus_bieti"] 88 | group4 = ["Chlorocebus_sabaeus", "Papio_anubis"] 89 | 90 | tv.highlight(group1, "orange", area="full") 91 | tv.highlight(group2, "skyblue", area="full") 92 | tv.highlight(group3, "lime", area="full") 93 | tv.highlight(group4, "pink", area="full") 94 | 95 | tv.link(group3, group4, connectionstyle="arc3,rad=0.2") 96 | 97 | fig = tv.plotfig() 98 | 99 | _ = fig.legend( 100 | handles=[ 101 | Patch(label="group1", color="orange"), 102 | Patch(label="group2", color="skyblue"), 103 | Patch(label="group3", color="lime"), 104 | Patch(label="group4", color="pink"), 105 | ], 106 | frameon=False, 107 | bbox_to_anchor=(0.3, 0.3), 108 | loc="center", 109 | ncols=2, 110 | ) 111 | 112 | fig.savefig(str(outfile), dpi=300) 113 | assert outfile.exists() 114 | -------------------------------------------------------------------------------- /src/phytreeviz/scripts/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | from pathlib import Path 5 | 6 | import phytreeviz 7 | from phytreeviz import TreeViz 8 | 9 | 10 | def main(): 11 | """Main function called from CLI""" 12 | args = get_args() 13 | run(**args.__dict__) 14 | 15 | 16 | def run( 17 | intree: str | Path, 18 | outfile: Path, 19 | format: str = "newick", 20 | fig_height: float = 0.5, 21 | fig_width: float = 8.0, 22 | leaf_label_size: int = 12, 23 | ignore_branch_length: bool = False, 24 | align_leaf_label: bool = False, 25 | show_branch_length: bool = False, 26 | show_confidence: bool = False, 27 | dpi: int = 300, 28 | ): 29 | """Run phylogenetic tree plot""" 30 | tp = TreeViz( 31 | intree, 32 | format=format, 33 | height=fig_height, 34 | width=fig_width, 35 | leaf_label_size=leaf_label_size, 36 | ignore_branch_length=ignore_branch_length, 37 | align_leaf_label=align_leaf_label, 38 | ) 39 | tp.show_scale_bar() 40 | if show_branch_length: 41 | tp.show_branch_length() 42 | if show_confidence: 43 | tp.show_confidence() 44 | tp.savefig(outfile, dpi=dpi) 45 | 46 | 47 | def get_args() -> argparse.Namespace: 48 | """Get arguments 49 | 50 | Returns 51 | ------- 52 | args : argparse.Namespace 53 | Argument parameters 54 | """ 55 | 56 | class CustomHelpFormatter(argparse.RawTextHelpFormatter): 57 | def __init__(self, prog, indent_increment=2, max_help_position=40, width=None): 58 | super().__init__(prog, indent_increment, max_help_position, width) 59 | 60 | format_list = ["newick", "phyloxml", "nexus", "nexml", "cdao"] 61 | 62 | desc = "Simple phylogenetic tree visualization CLI tool" 63 | parser = argparse.ArgumentParser( 64 | description=desc, 65 | add_help=False, 66 | formatter_class=CustomHelpFormatter, 67 | epilog=f"Available Tree Format: {format_list}", 68 | ) 69 | 70 | ####################################################### 71 | # General options 72 | ####################################################### 73 | general_opts = parser.add_argument_group("General Options") 74 | general_opts.add_argument( 75 | "-i", 76 | "--intree", 77 | type=str, 78 | help="Input phylogenetic tree file or text", 79 | required=True, 80 | metavar="IN", 81 | ) 82 | general_opts.add_argument( 83 | "-o", 84 | "--outfile", 85 | type=Path, 86 | help="Output phylogenetic tree plot file [*.png|*.jpg|*.svg|*.pdf]", 87 | required=True, 88 | metavar="OUT", 89 | ) 90 | default_tree_format = "newick" 91 | general_opts.add_argument( 92 | "--format", 93 | type=str, 94 | help=f"Input phylogenetic tree format (Default: '{default_tree_format}')", 95 | default=default_tree_format, 96 | choices=format_list, 97 | metavar="", 98 | ) 99 | general_opts.add_argument( 100 | "-v", 101 | "--version", 102 | version=f"v{phytreeviz.__version__}", 103 | help="Print version information", 104 | action="version", 105 | ) 106 | general_opts.add_argument( 107 | "-h", 108 | "--help", 109 | help="Show this help message and exit", 110 | action="help", 111 | ) 112 | 113 | ####################################################### 114 | # Figure appearence options 115 | ####################################################### 116 | fig_opts = parser.add_argument_group("Figure Appearence Options") 117 | default_height = 0.5 118 | fig_opts.add_argument( 119 | "--fig_height", 120 | type=float, 121 | help=f"Figure height per leaf node of tree (Default: {default_height})", 122 | default=default_height, 123 | metavar="", 124 | ) 125 | default_width = 8.0 126 | fig_opts.add_argument( 127 | "--fig_width", 128 | type=float, 129 | help=f"Figure width (Default: {default_width})", 130 | default=default_width, 131 | metavar="", 132 | ) 133 | default_leaf_label_size = 12 134 | fig_opts.add_argument( 135 | "--leaf_label_size", 136 | type=int, 137 | help=f"Leaf label size (Default: {default_leaf_label_size})", 138 | default=default_leaf_label_size, 139 | metavar="", 140 | ) 141 | fig_opts.add_argument( 142 | "--ignore_branch_length", 143 | help="Ignore branch length for plotting tree (Default: OFF)", 144 | action="store_true", 145 | ) 146 | fig_opts.add_argument( 147 | "--align_leaf_label", 148 | help="Align leaf label position (Default: OFF)", 149 | action="store_true", 150 | ) 151 | fig_opts.add_argument( 152 | "--show_branch_length", 153 | help="Show branch length (Default: OFF)", 154 | action="store_true", 155 | ) 156 | fig_opts.add_argument( 157 | "--show_confidence", 158 | help="Show confidence (Default: OFF)", 159 | action="store_true", 160 | ) 161 | default_dpi = 300 162 | fig_opts.add_argument( 163 | "--dpi", 164 | type=int, 165 | help=f"Figure DPI (Default: {default_dpi})", 166 | default=default_dpi, 167 | metavar="", 168 | ) 169 | return parser.parse_args() 170 | 171 | 172 | if __name__ == "__main__": 173 | main() 174 | -------------------------------------------------------------------------------- /example/large_example.nwk: -------------------------------------------------------------------------------- 1 | ((Tachyglossus_aculeatus:0.041039,Ornithorhynchus_anatinus:0.032667):0.151655,(((Monodelphis_domestica:0.023161,Gracilinanus_agilis:0.019479):0.044743,((Dromiciops_gliroides:0.04538,(Antechinus_flavipes:0.012552,Sarcophilus_harrisii:0.013098):0.049783):0.0,(Trichosurus_vulpecula:0.031435,(Phascolarctos_cinereus:0.019468,Vombatus_ursinus:0.0201):0.013778):0.009855):0.011865):0.14674,(((Choloepus_didactylus:0.051362,Dasypus_novemcinctus:0.0628):0.030968,((Trichechus_manatus_latirostris:0.034498,(Elephas_maximus_indicus:0.001934,Loxodonta_africana:0.002948):0.038159):0.014145,((Orycteropus_afer_afer:0.060603,Elephantulus_edwardii:0.111994):0.0,(Echinops_telfairi:0.122896,Chrysochloris_asiatica:0.077968):0.004724):0.003366):0.028953):0.003561,((((Galeopterus_variegatus:0.058224,Tupaia_chinensis:0.096364):0.0,((Otolemur_garnettii:0.061401,(Propithecus_coquereli:0.020446,(Microcebus_murinus:0.028666,Lemur_catta:0.021612):0.0):0.020103):0.016933,(Carlito_syrichta:0.074345,((Aotus_nancymaae:0.012121,(Callithrix_jacchus:0.015702,(Saimiri_boliviensis_boliviensis:0.013572,(Sapajus_apella:0.002932,Cebus_imitator:0.003296):0.008564):0.001747):0.0):0.019315,(((Hylobates_moloch:0.00333,Nomascus_leucogenys:0.003123):0.006897,(Pongo_abelii:0.008478,(Gorilla_gorilla_gorilla:0.004021,(Homo_sapiens:0.003164,(Pan_troglodytes:0.001144,Pan_paniscus:0.001106):0.001865):0.001052):0.003929):0.00114):0.004949,(((Colobus_angolensis_palliatus:0.005275,Piliocolobus_tephrosceles:0.004777):0.001021,(Trachypithecus_francoisi:0.004061,(Rhinopithecus_roxellana:9.94E-4,Rhinopithecus_bieti:0.002571):0.00248):0.001915):0.002828,(Chlorocebus_sabaeus:0.005852,((Macaca_nemestrina:0.002166,(Macaca_thibetana_thibetana:0.001527,(Macaca_fascicularis:0.001199,Macaca_mulatta:0.001166):3.96E-4):3.7E-5):0.001884,((Mandrillus_leucophaeus:0.00342,Cercocebus_atys:0.003303):4.58E-4,(Papio_anubis:0.002032,Theropithecus_gelada:0.001939):8.91E-4):5.6E-4):0.00146):0.00244):0.009713):0.009444):0.032854):0.003177):0.007546):8.37E-4,((Oryctolagus_cuniculus:0.058469,(Ochotona_curzoniae:0.018974,Ochotona_princeps:0.017454):0.087191):0.055735,((((Heterocephalus_glaber:0.031255,Fukomys_damarensis:0.040089):0.016404,(Cavia_porcellus:0.059037,(Chinchilla_lanigera:0.040164,Octodon_degus:0.057341):0.005638):0.012104):0.056969,((Urocitellus_parryii:0.004837,Ictidomys_tridecemlineatus:0.005738):0.00319,(Marmota_marmota_marmota:0.002762,(Marmota_monax:0.002463,Marmota_flaviventris:0.002079):0.0):0.004337):0.067595):0.0,((Castor_canadensis:0.064132,(Perognathus_longimembris_pacificus:0.047365,(Dipodomys_spectabilis:0.005613,Dipodomys_ordii:0.007268):0.03364):0.069847):0.007988,(Jaculus_jaculus:0.099935,(Nannospalax_galili:0.067109,(((Acomys_russatus:0.053457,Meriones_unguiculatus:0.054028):0.006455,((Rattus_norvegicus:0.005842,Rattus_rattus:0.006673):0.034271,((Grammomys_surdaster:0.018483,Arvicanthis_niloticus:0.018568):0.011928,(Apodemus_sylvaticus:0.037346,(Mastomys_coucha:0.028128,(Mus_pahari:0.018467,(Mus_musculus:0.00926,Mus_caroli:0.009222):0.009302):0.013992):0.001743):0.002912):0.002225):0.026092):0.006639,((Onychomys_torridus:0.020338,(Peromyscus_californicus_insignis:0.010724,(Peromyscus_maniculatus_bairdii:0.006007,Peromyscus_leucopus:0.00573):0.010541):0.005231):0.022089,((Phodopus_roborovskii:0.031875,(Cricetulus_griseus:0.024581,Mesocricetus_auratus:0.030155):0.003172):0.013321,(Myodes_glareolus:0.017788,(Arvicola_amphibius:0.015589,(Microtus_fortis:0.00926,(Microtus_ochrogaster:0.007048,Microtus_oregoni:0.006925):0.002372):0.007538):0.002737):0.032918):5.51E-4):0.012794):0.044807):0.020382):0.020997):0.00865):0.013867):0.004763):0.009498,(((Talpa_occidentalis:0.034179,Condylura_cristata:0.0477):0.051753,(Erinaceus_europaeus:0.138675,(Suncus_etruscus:0.101524,Sorex_araneus:0.08657):0.082828):0.01047):0.01438,((((Rhinolophus_ferrumequinum:0.031471,Hipposideros_armiger:0.032399):0.021734,(Rousettus_aegyptiacus:0.022455,(Pteropus_alecto:0.003701,(Pteropus_vampyrus:0.002,Pteropus_giganteus:0.002104):0.001403):0.011809):0.040275):0.004114,((Desmodus_rotundus:0.026345,((Phyllostomus_discolor:0.010528,Phyllostomus_hastatus:0.00889):0.021179,(Artibeus_jamaicensis:0.020278,Sturnira_hondurensis:0.021968):0.011022):0.006395):0.038395,(Molossus_molossus:0.048736,(Miniopterus_natalensis:0.047506,((Pipistrellus_kuhlii:0.045693,Eptesicus_fuscus:0.012037):0.008305,((Myotis_davidii:0.01195,Myotis_myotis:0.007549):0.003267,(Myotis_lucifugus:0.005237,Myotis_brandtii:0.006754):0.001948):0.011622):0.033082):0.003907):0.006671):0.015341):0.01496,(((Ceratotherium_simum_simum:0.030622,((Equus_asinus:0.001561,Equus_quagga:0.001671):0.001588,(Equus_caballus:7.55E-4,Equus_przewalskii:0.001271):0.002407):0.03485):0.018203,((Vicugna_pacos:0.007718,(Camelus_dromedarius:0.002158,(Camelus_ferus:5.56E-4,Camelus_bactrianus:0.00138):8.61E-4):0.005174):0.046126,(Sus_scrofa:0.058797,(((Odocoileus_virginianus_texanus:0.011514,(Cervus_canadensis:0.001134,Cervus_elaphus:9.53E-4):0.007974):0.01094,((Oryx_dammah:0.010016,(Budorcas_taxicolor:0.005618,(Capra_hircus:0.004961,Ovis_aries:0.005211):8.56E-4):0.004159):0.006643,(Bubalus_bubalis:0.007784,((Bison_bison_bison:0.002949,Bos_mutus:0.00256):7.75E-4,(Bos_taurus:0.001308,Bos_indicus:0.00139):0.001056):0.005658):0.006567):0.004971):0.045229,((Balaenoptera_musculus:0.003928,Balaenoptera_acutorostrata_scammoni:0.005117):0.006735,(Physeter_catodon:0.011701,(Lipotes_vexillifer:0.00984,(((Monodon_monoceros:0.001652,Delphinapterus_leucas:0.001686):0.002258,(Neophocaena_asiaeorientalis_asiaeorientalis:0.001875,Phocoena_sinus:0.001814):0.003335):0.001772,(Orcinus_orca:0.002651,(Lagenorhynchus_obliquidens:0.002594,(Tursiops_truncatus:0.002347,Globicephala_melas:0.002389):1.6E-4):3.39E-4):0.003465):0.002546):0.004627):0.001056):0.021301):0.008221):0.003537):0.021401):2.63E-4,((Manis_pentadactyla:0.008628,Manis_javanica:0.010455):0.066826,(((Hyaena_hyaena:0.025938,Suricata_suricatta:0.033926):0.009827,((Panthera_tigris:0.001588,(Panthera_uncia:0.002026,(Panthera_leo:0.001604,Panthera_pardus:0.001384):5.89E-4):0.0):0.0032,(Leopardus_geoffroyi:0.004188,((Felis_catus:0.00344,(Lynx_canadensis:0.003815,Prionailurus_bengalensis:0.003718):0.0):2.06E-4,(Acinonyx_jubatus:0.002889,(Puma_yagouaroundi:0.002737,Puma_concolor:0.003565):4.61E-4):0.001087):3.86E-4):6.66E-4):0.018981):0.019759,((Canis_lupus_dingo:0.005075,(Vulpes_lagopus:0.00214,Vulpes_vulpes:0.00204):0.003802):0.038703,((Meles_meles:0.013989,((Lontra_canadensis:0.007132,Enhydra_lutris_kenyoni:0.006685):0.003833,(Neogale_vison:0.00697,(Mustela_erminea:0.003844,Mustela_putorius_furo:0.004712):0.003036):0.005606):0.00278):0.027582,((Ailuropoda_melanoleuca:0.010243,(Ursus_americanus:0.001562,(Ursus_maritimus:0.001219,Ursus_arctos:9.48E-4):4.69E-4):0.006924):0.018488,((Odobenus_rosmarus_divergens:0.005668,(Callorhinus_ursinus:0.00245,(Zalophus_californianus:0.001235,Eumetopias_jubatus:0.001192):0.001217):0.003828):0.006737,((Phoca_vitulina:0.001376,Halichoerus_grypus:0.001964):0.00413,(Neomonachus_schauinslandi:0.003865,(Leptonychotes_weddellii:0.003686,(Mirounga_leonina:9.7E-4,Mirounga_angustirostris:0.001092):0.003679):3.11E-4):0.002457):0.005457):0.012989):0.0):0.009482):0.008295):0.025497):0.001876):4.5E-4):0.003616):0.011384):0.007673):0.16004):0.151655); 2 | -------------------------------------------------------------------------------- /src/phytreeviz/utils/example_data/large_example.nwk: -------------------------------------------------------------------------------- 1 | ((Tachyglossus_aculeatus:0.041039,Ornithorhynchus_anatinus:0.032667):0.151655,(((Monodelphis_domestica:0.023161,Gracilinanus_agilis:0.019479):0.044743,((Dromiciops_gliroides:0.04538,(Antechinus_flavipes:0.012552,Sarcophilus_harrisii:0.013098):0.049783):0.0,(Trichosurus_vulpecula:0.031435,(Phascolarctos_cinereus:0.019468,Vombatus_ursinus:0.0201):0.013778):0.009855):0.011865):0.14674,(((Choloepus_didactylus:0.051362,Dasypus_novemcinctus:0.0628):0.030968,((Trichechus_manatus_latirostris:0.034498,(Elephas_maximus_indicus:0.001934,Loxodonta_africana:0.002948):0.038159):0.014145,((Orycteropus_afer_afer:0.060603,Elephantulus_edwardii:0.111994):0.0,(Echinops_telfairi:0.122896,Chrysochloris_asiatica:0.077968):0.004724):0.003366):0.028953):0.003561,((((Galeopterus_variegatus:0.058224,Tupaia_chinensis:0.096364):0.0,((Otolemur_garnettii:0.061401,(Propithecus_coquereli:0.020446,(Microcebus_murinus:0.028666,Lemur_catta:0.021612):0.0):0.020103):0.016933,(Carlito_syrichta:0.074345,((Aotus_nancymaae:0.012121,(Callithrix_jacchus:0.015702,(Saimiri_boliviensis_boliviensis:0.013572,(Sapajus_apella:0.002932,Cebus_imitator:0.003296):0.008564):0.001747):0.0):0.019315,(((Hylobates_moloch:0.00333,Nomascus_leucogenys:0.003123):0.006897,(Pongo_abelii:0.008478,(Gorilla_gorilla_gorilla:0.004021,(Homo_sapiens:0.003164,(Pan_troglodytes:0.001144,Pan_paniscus:0.001106):0.001865):0.001052):0.003929):0.00114):0.004949,(((Colobus_angolensis_palliatus:0.005275,Piliocolobus_tephrosceles:0.004777):0.001021,(Trachypithecus_francoisi:0.004061,(Rhinopithecus_roxellana:9.94E-4,Rhinopithecus_bieti:0.002571):0.00248):0.001915):0.002828,(Chlorocebus_sabaeus:0.005852,((Macaca_nemestrina:0.002166,(Macaca_thibetana_thibetana:0.001527,(Macaca_fascicularis:0.001199,Macaca_mulatta:0.001166):3.96E-4):3.7E-5):0.001884,((Mandrillus_leucophaeus:0.00342,Cercocebus_atys:0.003303):4.58E-4,(Papio_anubis:0.002032,Theropithecus_gelada:0.001939):8.91E-4):5.6E-4):0.00146):0.00244):0.009713):0.009444):0.032854):0.003177):0.007546):8.37E-4,((Oryctolagus_cuniculus:0.058469,(Ochotona_curzoniae:0.018974,Ochotona_princeps:0.017454):0.087191):0.055735,((((Heterocephalus_glaber:0.031255,Fukomys_damarensis:0.040089):0.016404,(Cavia_porcellus:0.059037,(Chinchilla_lanigera:0.040164,Octodon_degus:0.057341):0.005638):0.012104):0.056969,((Urocitellus_parryii:0.004837,Ictidomys_tridecemlineatus:0.005738):0.00319,(Marmota_marmota_marmota:0.002762,(Marmota_monax:0.002463,Marmota_flaviventris:0.002079):0.0):0.004337):0.067595):0.0,((Castor_canadensis:0.064132,(Perognathus_longimembris_pacificus:0.047365,(Dipodomys_spectabilis:0.005613,Dipodomys_ordii:0.007268):0.03364):0.069847):0.007988,(Jaculus_jaculus:0.099935,(Nannospalax_galili:0.067109,(((Acomys_russatus:0.053457,Meriones_unguiculatus:0.054028):0.006455,((Rattus_norvegicus:0.005842,Rattus_rattus:0.006673):0.034271,((Grammomys_surdaster:0.018483,Arvicanthis_niloticus:0.018568):0.011928,(Apodemus_sylvaticus:0.037346,(Mastomys_coucha:0.028128,(Mus_pahari:0.018467,(Mus_musculus:0.00926,Mus_caroli:0.009222):0.009302):0.013992):0.001743):0.002912):0.002225):0.026092):0.006639,((Onychomys_torridus:0.020338,(Peromyscus_californicus_insignis:0.010724,(Peromyscus_maniculatus_bairdii:0.006007,Peromyscus_leucopus:0.00573):0.010541):0.005231):0.022089,((Phodopus_roborovskii:0.031875,(Cricetulus_griseus:0.024581,Mesocricetus_auratus:0.030155):0.003172):0.013321,(Myodes_glareolus:0.017788,(Arvicola_amphibius:0.015589,(Microtus_fortis:0.00926,(Microtus_ochrogaster:0.007048,Microtus_oregoni:0.006925):0.002372):0.007538):0.002737):0.032918):5.51E-4):0.012794):0.044807):0.020382):0.020997):0.00865):0.013867):0.004763):0.009498,(((Talpa_occidentalis:0.034179,Condylura_cristata:0.0477):0.051753,(Erinaceus_europaeus:0.138675,(Suncus_etruscus:0.101524,Sorex_araneus:0.08657):0.082828):0.01047):0.01438,((((Rhinolophus_ferrumequinum:0.031471,Hipposideros_armiger:0.032399):0.021734,(Rousettus_aegyptiacus:0.022455,(Pteropus_alecto:0.003701,(Pteropus_vampyrus:0.002,Pteropus_giganteus:0.002104):0.001403):0.011809):0.040275):0.004114,((Desmodus_rotundus:0.026345,((Phyllostomus_discolor:0.010528,Phyllostomus_hastatus:0.00889):0.021179,(Artibeus_jamaicensis:0.020278,Sturnira_hondurensis:0.021968):0.011022):0.006395):0.038395,(Molossus_molossus:0.048736,(Miniopterus_natalensis:0.047506,((Pipistrellus_kuhlii:0.045693,Eptesicus_fuscus:0.012037):0.008305,((Myotis_davidii:0.01195,Myotis_myotis:0.007549):0.003267,(Myotis_lucifugus:0.005237,Myotis_brandtii:0.006754):0.001948):0.011622):0.033082):0.003907):0.006671):0.015341):0.01496,(((Ceratotherium_simum_simum:0.030622,((Equus_asinus:0.001561,Equus_quagga:0.001671):0.001588,(Equus_caballus:7.55E-4,Equus_przewalskii:0.001271):0.002407):0.03485):0.018203,((Vicugna_pacos:0.007718,(Camelus_dromedarius:0.002158,(Camelus_ferus:5.56E-4,Camelus_bactrianus:0.00138):8.61E-4):0.005174):0.046126,(Sus_scrofa:0.058797,(((Odocoileus_virginianus_texanus:0.011514,(Cervus_canadensis:0.001134,Cervus_elaphus:9.53E-4):0.007974):0.01094,((Oryx_dammah:0.010016,(Budorcas_taxicolor:0.005618,(Capra_hircus:0.004961,Ovis_aries:0.005211):8.56E-4):0.004159):0.006643,(Bubalus_bubalis:0.007784,((Bison_bison_bison:0.002949,Bos_mutus:0.00256):7.75E-4,(Bos_taurus:0.001308,Bos_indicus:0.00139):0.001056):0.005658):0.006567):0.004971):0.045229,((Balaenoptera_musculus:0.003928,Balaenoptera_acutorostrata_scammoni:0.005117):0.006735,(Physeter_catodon:0.011701,(Lipotes_vexillifer:0.00984,(((Monodon_monoceros:0.001652,Delphinapterus_leucas:0.001686):0.002258,(Neophocaena_asiaeorientalis_asiaeorientalis:0.001875,Phocoena_sinus:0.001814):0.003335):0.001772,(Orcinus_orca:0.002651,(Lagenorhynchus_obliquidens:0.002594,(Tursiops_truncatus:0.002347,Globicephala_melas:0.002389):1.6E-4):3.39E-4):0.003465):0.002546):0.004627):0.001056):0.021301):0.008221):0.003537):0.021401):2.63E-4,((Manis_pentadactyla:0.008628,Manis_javanica:0.010455):0.066826,(((Hyaena_hyaena:0.025938,Suricata_suricatta:0.033926):0.009827,((Panthera_tigris:0.001588,(Panthera_uncia:0.002026,(Panthera_leo:0.001604,Panthera_pardus:0.001384):5.89E-4):0.0):0.0032,(Leopardus_geoffroyi:0.004188,((Felis_catus:0.00344,(Lynx_canadensis:0.003815,Prionailurus_bengalensis:0.003718):0.0):2.06E-4,(Acinonyx_jubatus:0.002889,(Puma_yagouaroundi:0.002737,Puma_concolor:0.003565):4.61E-4):0.001087):3.86E-4):6.66E-4):0.018981):0.019759,((Canis_lupus_dingo:0.005075,(Vulpes_lagopus:0.00214,Vulpes_vulpes:0.00204):0.003802):0.038703,((Meles_meles:0.013989,((Lontra_canadensis:0.007132,Enhydra_lutris_kenyoni:0.006685):0.003833,(Neogale_vison:0.00697,(Mustela_erminea:0.003844,Mustela_putorius_furo:0.004712):0.003036):0.005606):0.00278):0.027582,((Ailuropoda_melanoleuca:0.010243,(Ursus_americanus:0.001562,(Ursus_maritimus:0.001219,Ursus_arctos:9.48E-4):4.69E-4):0.006924):0.018488,((Odobenus_rosmarus_divergens:0.005668,(Callorhinus_ursinus:0.00245,(Zalophus_californianus:0.001235,Eumetopias_jubatus:0.001192):0.001217):0.003828):0.006737,((Phoca_vitulina:0.001376,Halichoerus_grypus:0.001964):0.00413,(Neomonachus_schauinslandi:0.003865,(Leptonychotes_weddellii:0.003686,(Mirounga_leonina:9.7E-4,Mirounga_angustirostris:0.001092):0.003679):3.11E-4):0.002457):0.005457):0.012989):0.0):0.009482):0.008295):0.025497):0.001876):4.5E-4):0.003616):0.011384):0.007673):0.16004):0.151655); 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phyTreeViz 2 | 3 | ![Python3](https://img.shields.io/badge/Language-Python3-steelblue) 4 | ![OS](https://img.shields.io/badge/OS-_Windows_|_Mac_|_Linux-steelblue) 5 | ![License](https://img.shields.io/badge/License-MIT-steelblue) 6 | [![Latest PyPI version](https://img.shields.io/pypi/v/phytreeviz.svg)](https://pypi.python.org/pypi/phytreeviz) 7 | [![conda-forge](https://img.shields.io/conda/vn/conda-forge/phytreeviz.svg?color=green)](https://anaconda.org/conda-forge/phytreeviz) 8 | [![CI](https://github.com/moshi4/phyTreeViz/actions/workflows/ci.yml/badge.svg)](https://github.com/moshi4/phyTreeViz/actions/workflows/ci.yml) 9 | 10 | ## Table of contents 11 | 12 | - [Overview](#overview) 13 | - [Installation](#installation) 14 | - [API Usage](#api-usage) 15 | - [CLI Usage](#cli-usage) 16 | 17 | ## Overview 18 | 19 | phyTreeViz is a simple and minimal phylogenetic tree visualization python package implemented based on matplotlib. 20 | This package was developed to enhance phylogenetic tree visualization functionality of BioPython. 21 | 22 | phyTreeViz is intended to provide a simple and easy-to-use phylogenetic tree visualization function without complexity. 23 | Therefore, if you need complex tree annotations, I recommend using [ete](https://github.com/etetoolkit/ete) or [ggtree](https://github.com/YuLab-SMU/ggtree). 24 | 25 | ## Installation 26 | 27 | `Python 3.8 or later` is required for installation. 28 | 29 | **Install PyPI package:** 30 | 31 | pip install phytreeviz 32 | 33 | **Install conda-forge package:** 34 | 35 | conda install -c conda-forge phytreeviz 36 | 37 | ## API Usage 38 | 39 | Only simple example usage is described in this section. 40 | For more details, please see [Getting Started](https://moshi4.github.io/phyTreeViz/getting_started/) and [API Docs](https://moshi4.github.io/phyTreeViz/api-docs/treeviz/). 41 | 42 | ### API Example 43 | 44 | #### API Example 1 45 | 46 | ```python 47 | from phytreeviz import TreeViz, load_example_tree_file 48 | 49 | tree_file = load_example_tree_file("small_example.nwk") 50 | 51 | tv = TreeViz(tree_file) 52 | tv.show_branch_length(color="red") 53 | tv.show_confidence(color="blue") 54 | tv.show_scale_bar() 55 | 56 | tv.savefig("api_example01.png", dpi=300) 57 | ``` 58 | 59 | ![example01.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/api_example01.png) 60 | 61 | #### API Example 2 62 | 63 | ```python 64 | from phytreeviz import TreeViz, load_example_tree_file 65 | 66 | tree_file = load_example_tree_file("small_example.nwk") 67 | 68 | tv = TreeViz(tree_file, height=0.7) 69 | tv.show_scale_axis() 70 | 71 | tv.set_node_label_props("Homo_sapiens", color="grey") 72 | tv.set_node_label_props("Pongo_abelii", color="green", style="italic") 73 | 74 | tv.set_node_line_props(["Hylobates_moloch", "Nomascus_leucogenys"], color="orange", lw=2) 75 | tv.set_node_line_props(["Homo_sapiens", "Pan_troglodytes", "Pan_paniscus"], color="magenta", ls="dotted") 76 | 77 | tv.savefig("api_example02.png", dpi=300) 78 | ``` 79 | 80 | ![example02.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/api_example02.png) 81 | 82 | #### API Example 3 83 | 84 | ```python 85 | from phytreeviz import TreeViz, load_example_tree_file 86 | 87 | tree_file = load_example_tree_file("small_example.nwk") 88 | 89 | tv = TreeViz(tree_file, align_leaf_label=True) 90 | tv.show_scale_axis() 91 | 92 | group1 = ["Hylobates_moloch", "Nomascus_leucogenys"] 93 | group2 = ["Homo_sapiens", "Pan_paniscus"] 94 | 95 | tv.highlight(group1, "orange") 96 | tv.highlight(group2, "lime") 97 | 98 | tv.annotate(group1, "group1") 99 | tv.annotate(group2, "group2") 100 | 101 | tv.marker(group1, marker="s", color="blue") 102 | tv.marker(group2, marker="D", color="purple", descendent=True) 103 | tv.marker("Pongo_abelii", color="red") 104 | 105 | tv.savefig("api_example03.png", dpi=300) 106 | ``` 107 | 108 | ![example03.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/api_example03.png) 109 | 110 | #### API Example 4 111 | 112 | ```python 113 | from phytreeviz import TreeViz, load_example_tree_file 114 | from matplotlib.patches import Patch 115 | 116 | tree_file = load_example_tree_file("medium_example.nwk") 117 | 118 | tv = TreeViz(tree_file, height=0.3, align_leaf_label=True, leaf_label_size=10) 119 | tv.show_scale_bar() 120 | 121 | group1 = ["Hylobates_moloch", "Nomascus_leucogenys"] 122 | group2 = ["Homo_sapiens", "Pongo_abelii"] 123 | group3 = ["Piliocolobus_tephrosceles", "Rhinopithecus_bieti"] 124 | group4 = ["Chlorocebus_sabaeus", "Papio_anubis"] 125 | 126 | tv.highlight(group1, "orange", area="full") 127 | tv.highlight(group2, "skyblue", area="full") 128 | tv.highlight(group3, "lime", area="full") 129 | tv.highlight(group4, "pink", area="full") 130 | 131 | tv.link(group3, group4, connectionstyle="arc3,rad=0.2") 132 | 133 | fig = tv.plotfig() 134 | 135 | _ = fig.legend( 136 | handles=[ 137 | Patch(label="group1", color="orange"), 138 | Patch(label="group2", color="skyblue"), 139 | Patch(label="group3", color="lime"), 140 | Patch(label="group4", color="pink"), 141 | ], 142 | frameon=False, 143 | bbox_to_anchor=(0.3, 0.3), 144 | loc="center", 145 | ncols=2, 146 | ) 147 | 148 | fig.savefig("api_example04.png", dpi=300) 149 | ``` 150 | 151 | ![example04.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/api_example04.png) 152 | 153 | ## CLI Usage 154 | 155 | phyTreeViz provides simple phylogenetic tree visualization CLI. 156 | 157 | ### Basic Command 158 | 159 | phytreeviz -i [Tree file or text] -o [Tree visualization file] 160 | 161 | ### Options 162 | 163 | General Options: 164 | -i IN, --intree IN Input phylogenetic tree file or text 165 | -o OUT, --outfile OUT Output phylogenetic tree plot file [*.png|*.jpg|*.svg|*.pdf] 166 | --format Input phylogenetic tree format (Default: 'newick') 167 | -v, --version Print version information 168 | -h, --help Show this help message and exit 169 | 170 | Figure Appearence Options: 171 | --fig_height Figure height per leaf node of tree (Default: 0.5) 172 | --fig_width Figure width (Default: 8.0) 173 | --leaf_label_size Leaf label size (Default: 12) 174 | --ignore_branch_length Ignore branch length for plotting tree (Default: OFF) 175 | --align_leaf_label Align leaf label position (Default: OFF) 176 | --show_branch_length Show branch length (Default: OFF) 177 | --show_confidence Show confidence (Default: OFF) 178 | --dpi Figure DPI (Default: 300) 179 | 180 | Available Tree Format: ['newick', 'phyloxml', 'nexus', 'nexml', 'cdao'] 181 | 182 | ### CLI Example 183 | 184 | Click [here](https://github.com/moshi4/phyTreeViz/raw/main/example/example.zip) to download example tree files. 185 | 186 | #### CLI Example 1 187 | 188 | phytreeviz -i "((A,B),((C,D),(E,(F,G))));" -o cli_example01.png 189 | 190 | ![example01.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/cli_example01.png) 191 | 192 | #### CLI Example 2 193 | 194 | phytreeviz -i ./example/small_example.nwk -o cli_example02.png \ 195 | --show_branch_length --show_confidence 196 | 197 | ![example02.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/cli_example02.png) 198 | 199 | #### CLI Example 3 200 | 201 | phytreeviz -i ./example/medium_example.nwk -o cli_example03.png \ 202 | --fig_height 0.3 --align_leaf_label 203 | 204 | ![example03.png](https://raw.githubusercontent.com/moshi4/phyTreeViz/main/docs/images/cli_example03.png) 205 | -------------------------------------------------------------------------------- /src/phytreeviz/treeviz.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import os 5 | from collections import Counter, defaultdict 6 | from copy import deepcopy 7 | from functools import cached_property 8 | from pathlib import Path 9 | from typing import Any, Callable 10 | from urllib.parse import urlparse 11 | from urllib.request import urlopen 12 | 13 | import matplotlib.pyplot as plt 14 | import numpy as np 15 | from Bio import Phylo 16 | from Bio.Phylo.BaseTree import Clade, Tree 17 | from matplotlib.axes import Axes 18 | from matplotlib.figure import Figure 19 | from matplotlib.font_manager import FontProperties 20 | from matplotlib.patches import Patch, Rectangle 21 | from matplotlib.text import Text 22 | from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar 23 | 24 | 25 | class TreeViz: 26 | """Phylogenetic Tree Visualization Class""" 27 | 28 | def __init__( 29 | self, 30 | tree_data: str | Path | Tree, # type: ignore 31 | *, 32 | format: str = "newick", 33 | height: float = 0.5, 34 | width: float = 8, 35 | orientation: str = "right", 36 | align_leaf_label: bool = False, 37 | ignore_branch_length: bool = False, 38 | leaf_label_size: float = 12, 39 | innode_label_size: float = 0, 40 | show_auto_innode_label: bool = True, 41 | leaf_label_xmargin_ratio: float = 0.01, 42 | innode_label_xmargin_ratio: float = 0.01, 43 | reverse: bool = False, 44 | ): 45 | """ 46 | Parameters 47 | ---------- 48 | tree_data : str | Path | Tree 49 | Tree data (`File`|`File URL`|`Tree Object`|`Tree String`) 50 | format : str, optional 51 | Tree format (`newick`|`phyloxml`|`nexus`|`nexml`|`cdao`) 52 | height : float, optional 53 | Figure height per leaf node of tree 54 | width : float, optional 55 | Figure width 56 | orientation : str, optional 57 | Tree orientation (`right`|`left`) 58 | align_leaf_label: bool, optional 59 | If True, align leaf label. 60 | ignore_branch_length : bool, optional 61 | If True, Ignore branch length for plotting tree. 62 | leaf_label_size : float, optional 63 | Leaf label size 64 | innode_label_size : float, optional 65 | Internal node label size 66 | show_auto_innode_label : bool, optional 67 | If True, show auto defined internal node label 68 | (e.g. `N_1`, `N_2`, ..., `N_XX`) 69 | leaf_label_xmargin_ratio : float, optional 70 | Leaf label x margin ratio 71 | innode_label_xmargin_ratio : float, optional 72 | Internal node label x margin ratio 73 | reverse : bool, optional 74 | Plot tree in reverse order 75 | """ 76 | tree = self._load_tree(tree_data, format=format) 77 | 78 | # Set unique node name and branch length if not exists 79 | tree, auto_innode_labels = self._set_uniq_innode_name(tree) 80 | self._check_node_name_dup(tree) 81 | max_tree_depth = max(tree.depths().values()) 82 | if ignore_branch_length or max_tree_depth == 0: 83 | tree = self._to_ultrametric_tree(tree) 84 | self._tree = tree 85 | 86 | # Plot parameters 87 | self._figsize = (width, height * self.tree.count_terminals()) 88 | self._align_leaf_label = align_leaf_label 89 | self._leaf_label_size = leaf_label_size 90 | self._innode_label_size = innode_label_size 91 | self._show_auto_innode_label = show_auto_innode_label 92 | self._auto_innode_labels = auto_innode_labels 93 | self._leaf_label_xmargin_ratio = leaf_label_xmargin_ratio 94 | self._innode_label_xmargin_ratio = innode_label_xmargin_ratio 95 | self._reverse = reverse 96 | self._ax: Axes | None = None 97 | if orientation in ("right", "left"): 98 | self._orientation = orientation 99 | else: 100 | raise ValueError(f"{orientation=} is invalid (`right` or `left`).") 101 | 102 | self._node2label_props: dict[str, dict[str, Any]] = defaultdict(lambda: {}) 103 | self._node2line_props: dict[str, dict[str, Any]] = defaultdict(lambda: {}) 104 | 105 | self._tree_line_kws: dict[str, Any] = dict(color="black", lw=1, clip_on=False) 106 | self._tree_align_line_kws: dict[str, Any] = dict( 107 | lw=0.5, ls="dashed", alpha=0.5, clip_on=False 108 | ) 109 | 110 | # Plot objects 111 | self._plot_patches: list[Patch] = [] 112 | self._plot_funcs: list[Callable[[Axes], None]] = [] 113 | 114 | ############################################################ 115 | # Properties 116 | ############################################################ 117 | 118 | @property 119 | def tree(self) -> Tree: 120 | """BioPython's Tree Object""" 121 | return self._tree 122 | 123 | @property 124 | def figsize(self) -> tuple[float, float]: 125 | """Figure size""" 126 | return self._figsize 127 | 128 | @property 129 | def xlim(self) -> tuple[float, float]: 130 | """Axes xlim""" 131 | if self._orientation == "left": 132 | return (self.max_tree_depth, 0) 133 | else: 134 | return (0, self.max_tree_depth) 135 | 136 | @property 137 | def ylim(self) -> tuple[float, float]: 138 | """Axes ylim""" 139 | return (0, self.tree.count_terminals() + 1) 140 | 141 | @cached_property 142 | def leaf_labels(self) -> list[str]: 143 | """Leaf labels""" 144 | return [str(n.name) for n in self.tree.get_terminals()] 145 | 146 | @cached_property 147 | def innode_labels(self) -> list[str]: 148 | """Internal node labels""" 149 | return [str(n.name) for n in self.tree.get_nonterminals()] 150 | 151 | @cached_property 152 | def all_node_labels(self) -> list[str]: 153 | """All node labels""" 154 | return self.leaf_labels + self.innode_labels 155 | 156 | @cached_property 157 | def max_tree_depth(self) -> float: 158 | """Max tree depth (root -> leaf max branch length)""" 159 | return max(self.tree.depths().values()) 160 | 161 | @cached_property 162 | def name2xy(self) -> dict[str, tuple[float, float]]: 163 | """Tree node name & node xy coordinate dict (alias for `name2xy_right`)""" 164 | return self.name2xy_right 165 | 166 | @cached_property 167 | def name2xy_right(self) -> dict[str, tuple[float, float]]: 168 | """Tree node name & node right xy coordinate dict""" 169 | return self._calc_name2xy_pos("right") 170 | 171 | @cached_property 172 | def name2xy_center(self) -> dict[str, tuple[float, float]]: 173 | """Tree node name & node center xy coordinate dict""" 174 | return self._calc_name2xy_pos("center") 175 | 176 | @cached_property 177 | def name2xy_left(self) -> dict[str, tuple[float, float]]: 178 | """Tree node name & node left xy coordinate dict""" 179 | return self._calc_name2xy_pos("left") 180 | 181 | @cached_property 182 | def name2rect(self) -> dict[str, Rectangle]: 183 | """Tree node name & rectangle dict""" 184 | return self._calc_name2rect() 185 | 186 | @property 187 | def ax(self) -> Axes: 188 | """Plot axes 189 | 190 | Can't access `ax` property before calling `tv.plotfig()` method 191 | """ 192 | if self._ax is None: 193 | err_msg = "Can't access ax property before calling `tv.plotfig()` method" 194 | raise ValueError(err_msg) 195 | return self._ax 196 | 197 | ############################################################ 198 | # Public Method 199 | ############################################################ 200 | 201 | def show_branch_length( 202 | self, 203 | *, 204 | size: int = 8, 205 | xpos: str = "center", 206 | ypos: str = "top", 207 | xmargin_ratio: float = 0.01, 208 | ymargin_ratio: float = 0.05, 209 | label_formatter: Callable[[float], str] | None = None, 210 | **kwargs, 211 | ) -> None: 212 | """Show branch length text label on each branch 213 | 214 | Parameters 215 | ---------- 216 | size : int, optional 217 | Text size 218 | xpos : str, optional 219 | X position of plot text (`left`|`center`|`right`) 220 | ypos : str, optional 221 | Y position of plot text (`top`|`center`|`bottom`) 222 | xmargin_ratio : float, optional 223 | Text x margin ratio. If `xpos = center`, this param is ignored. 224 | ymargin_ratio : float, optional 225 | Text y margin ratio. If `ypos = center`, this param is ignored. 226 | label_formatter : Callable[[float], str] | None, optional 227 | User-defined branch length value label format function 228 | (e.g. `lambda v: f"{v:.3f}"`) 229 | **kwargs : dict, optional 230 | Text properties (e.g. `color="red", bbox=dict(color="skyblue"), ...`) 231 | 232 | """ 233 | node: Clade 234 | for node in self.tree.find_clades(): 235 | branch_length = node.branch_length 236 | if node == self.tree.root or branch_length is None: 237 | continue 238 | # Format label 239 | if label_formatter: 240 | label = label_formatter(float(branch_length)) 241 | elif str(branch_length).isdigit(): 242 | label = str(branch_length) 243 | else: 244 | label = f"{float(branch_length):.2f}" 245 | 246 | self.text_on_branch( 247 | str(node.name), 248 | label, 249 | size=size, 250 | xpos=xpos, 251 | ypos=ypos, 252 | xmargin_ratio=xmargin_ratio, 253 | ymargin_ratio=ymargin_ratio, 254 | **kwargs, 255 | ) 256 | 257 | def show_confidence( 258 | self, 259 | *, 260 | size: int = 8, 261 | xpos: str = "center", 262 | ypos: str = "bottom", 263 | xmargin_ratio: float = 0.01, 264 | ymargin_ratio: float = 0.05, 265 | label_formatter: Callable[[float], str] | None = None, 266 | **kwargs, 267 | ) -> None: 268 | """Show confidence text label on each branch 269 | 270 | Parameters 271 | ---------- 272 | size : int, optional 273 | Text size 274 | xpos : str, optional 275 | X position of plot text (`left`|`center`|`right`) 276 | ypos : str, optional 277 | Y position of plot text (`top`|`center`|`bottom`) 278 | xmargin_ratio : float, optional 279 | Text x margin ratio. If `xpos = center`, this param is ignored. 280 | ymargin_ratio : float, optional 281 | Text y margin ratio. If `ypos = center`, this param is ignored. 282 | label_formatter : Callable[[float], str] | None, optional 283 | User-defined confidence value label format function 284 | (e.g. `lambda v: f"{v:.3f}"`) 285 | **kwargs : dict, optional 286 | Text properties (e.g. `color="red", bbox=dict(color="skyblue"), ...`) 287 | 288 | """ 289 | node: Clade 290 | for node in self.tree.find_clades(): 291 | confidence = node.confidence 292 | if confidence is None: 293 | continue 294 | # Format label 295 | if label_formatter: 296 | label = label_formatter(float(confidence)) 297 | elif str(confidence).isdigit(): 298 | label = str(confidence) 299 | else: 300 | label = f"{float(confidence):.2f}" 301 | 302 | self.text_on_branch( 303 | str(node.name), 304 | label, 305 | size=size, 306 | xpos="right" if node == self.tree.root and xpos == "center" else xpos, 307 | ypos=ypos, 308 | xmargin_ratio=xmargin_ratio, 309 | ymargin_ratio=ymargin_ratio, 310 | **kwargs, 311 | ) 312 | 313 | def show_scale_axis( 314 | self, 315 | *, 316 | ticks_interval: float | None = None, 317 | ypos: float = 0, 318 | ) -> None: 319 | """Show scale axis 320 | 321 | Parameters 322 | ---------- 323 | ticks_interval : float | None, optional 324 | Ticks interval. If None, interval is automatically defined. 325 | ypos : float, optional 326 | Y position of axis. 327 | """ 328 | 329 | def plot_scale_axis(ax: Axes): 330 | ax.tick_params(bottom=True, labelbottom=True) 331 | ax.spines["bottom"].set_visible(True) 332 | ax.spines["bottom"].set_position(("data", ypos)) 333 | if ticks_interval: 334 | stop = self.max_tree_depth + (ticks_interval / 100) 335 | xticks = np.arange(0, stop, ticks_interval) 336 | ax.set_xticks(xticks) # type: ignore 337 | 338 | self._plot_funcs.append(plot_scale_axis) 339 | 340 | def show_scale_bar( 341 | self, 342 | *, 343 | scale_size: float | None = None, 344 | text_size: float = 8, 345 | loc: str = "lower left", 346 | label_top: bool = False, 347 | ) -> None: 348 | """Show scale bar 349 | 350 | Parameters 351 | ---------- 352 | scale_size : float | None, optional 353 | Scale size. If None, size is automatically defined. 354 | text_size : float | None, optional 355 | Text label size 356 | loc : str, optional 357 | Bar location (e.g. `lower left`, `upper left`) 358 | label_top : bool, optional 359 | If True, plot label on top. If False, plot label on bottom. 360 | """ 361 | 362 | def plot_scale_bar(ax: Axes): 363 | auto_size: float = ax.get_xticks()[1] # type: ignore 364 | scale = AnchoredSizeBar( 365 | ax.transData, 366 | size=auto_size if scale_size is None else scale_size, 367 | label=str(auto_size) if scale_size is None else str(scale_size), 368 | loc=loc, 369 | label_top=label_top, 370 | frameon=False, 371 | fontproperties=FontProperties(size=text_size), # type: ignore 372 | ) 373 | ax.add_artist(scale) 374 | 375 | self._plot_funcs.append(plot_scale_bar) 376 | 377 | def highlight( 378 | self, 379 | query: str | list[str] | tuple[str], 380 | color: str, 381 | *, 382 | alpha: float = 0.5, 383 | area: str = "branch-label", 384 | **kwargs, 385 | ) -> None: 386 | """Plot highlight for target node 387 | 388 | Parameters 389 | ---------- 390 | query : str | list[str] | tuple[str] 391 | Search query node name(s) for highlight. If multiple node names are set, 392 | MRCA(Most Recent Common Ancester) node is set. 393 | color : str 394 | Highlight color 395 | alpha : float 396 | Highlight color alpha(transparancy) value 397 | area : str 398 | Highlight area (`branch`|`branch-label`|`full`) 399 | **kwargs : dict, optional 400 | Rectangle properties (e.g. `alpha=0.5, ec="grey", lw=1.0, ...`) 401 | 402 | """ 403 | if area not in ("branch", "branch-label", "full"): 404 | raise ValueError(f"{area=} is invalid ('branch'|'branch-label'|'full').") 405 | 406 | # Set rectangle properties 407 | target_node_name = self._search_target_node_name(query) 408 | rect = deepcopy(self.name2rect[target_node_name]) 409 | rect.set_color(color) 410 | rect.set_alpha(alpha) 411 | kwargs.setdefault("lw", 0) 412 | kwargs.setdefault("zorder", 0) 413 | kwargs.setdefault("clip_on", False) 414 | rect.set(**kwargs) 415 | 416 | def plot_highlight(ax: Axes): 417 | if area == "branch-label": 418 | texts_rect = self._get_texts_rect(query) 419 | texts_rect_xmax = texts_rect.get_x() + texts_rect.get_width() 420 | rect.set_width(texts_rect_xmax - rect.get_x()) 421 | elif area == "full": 422 | texts_rect = self._get_texts_rect(self.leaf_labels) 423 | texts_rect_xmax = texts_rect.get_x() + texts_rect.get_width() 424 | rect.set_width(texts_rect_xmax - rect.get_x()) 425 | ax.add_patch(deepcopy(rect)) 426 | 427 | self._plot_funcs.append(plot_highlight) 428 | 429 | def annotate( 430 | self, 431 | query: str | list[str] | tuple[str], 432 | label: str, 433 | *, 434 | text_size: float = 10, 435 | text_color: str = "black", 436 | text_orientation: str = "horizontal", 437 | line_color: str = "black", 438 | xmargin_ratio: float = 0.01, 439 | align: bool = False, 440 | text_kws: dict[str, Any] | None = None, 441 | line_kws: dict[str, Any] | None = None, 442 | ) -> None: 443 | """Annotate tree clade with line & text label 444 | 445 | Parameters 446 | ---------- 447 | query : str | list[str] | tuple[str] 448 | Search query node name(s) for annotate. If multiple node names are set, 449 | MRCA(Most Recent Common Ancester) node is set. 450 | label : str 451 | Label name 452 | text_size : float, optional 453 | Text size 454 | text_color : str, optional 455 | Text color 456 | text_orientation : str, optional 457 | Text orientation (`horizontal`|`vertical`) 458 | line_color : str, optional 459 | Line color 460 | xmargin_ratio : float, optional 461 | X margin ratio 462 | align : bool, optional 463 | If True, annotate position is aligned to rightmost edge. 464 | text_kws : dict[str, Any] | None, optional 465 | Text properties 466 | 467 | line_kws : dict[str, Any] | None, optional 468 | Axes.plot properties (e.g. dict(lw=2.0, ls="dashed", ...)) 469 | 470 | """ 471 | text_kws = {} if text_kws is None else deepcopy(text_kws) 472 | line_kws = {} if line_kws is None else deepcopy(line_kws) 473 | 474 | line_kws.setdefault("lw", 1) 475 | line_kws.update(dict(color=line_color, clip_on=False)) 476 | text_kws.update(dict(size=text_size, color=text_color, va="center", ha="left")) 477 | if self._orientation == "left": 478 | text_kws.update(ha="right") 479 | if text_orientation == "horizontal": 480 | text_kws.update(dict(rotation=0)) 481 | elif text_orientation == "vertical": 482 | text_kws.update(dict(rotation=-90)) 483 | else: 484 | err_msg = f"{text_orientation=} is invalid (`horizontal`|`vertical)" 485 | raise ValueError(err_msg) 486 | 487 | def plot_annotate(ax: Axes) -> None: 488 | # Get target texts entire rectangle 489 | texts_rect = self._get_texts_rect(query) 490 | xmin, ymin = texts_rect.xy 491 | xmax, ymax = xmin + texts_rect.get_width(), ymin + texts_rect.get_height() 492 | xmargin = self.max_tree_depth * xmargin_ratio 493 | 494 | if align: 495 | all_texts_rect = self._get_texts_rect(self.leaf_labels) 496 | xmax = all_texts_rect.get_x() + all_texts_rect.get_width() 497 | 498 | # Plot annotate line 499 | line_x = [xmax + xmargin] * 2 500 | line_y = [ymin, ymax] 501 | ax.plot(line_x, line_y, **line_kws) 502 | 503 | # Plot annotate label 504 | text_x, text_y = line_x[0] + xmargin, sum(line_y) / 2 505 | ax.text(text_x, text_y, label, **text_kws) 506 | 507 | self._plot_funcs.append(plot_annotate) 508 | 509 | def marker( 510 | self, 511 | query: str | list[str] | tuple[str], 512 | marker: str = "o", 513 | *, 514 | size: int = 6, 515 | descendent: bool = False, 516 | **kwargs, 517 | ) -> None: 518 | """Plot marker on target node(s) 519 | 520 | Parameters 521 | ---------- 522 | query : str | list[str] | tuple[str] 523 | Search query node name(s) for plotting marker. 524 | If multiple node names are set, 525 | MRCA(Most Recent Common Ancester) node is set. 526 | marker : str, optional 527 | Marker type (e.g. `o`, `s`, `D`, `P`, `*`, `x`, `d`, `^`, `v`, `<`, `>`) 528 | 529 | size : int, optional 530 | Marker size 531 | descendent : bool, optional 532 | If True, plot markers on target node's descendent as well. 533 | **kwargs : dict, optional 534 | Axes.scatter properties (e.g. `color="red", ec="black", alpha=0.5, ...`) 535 | 536 | """ 537 | target_node_name = self._search_target_node_name(query) 538 | 539 | if descendent: 540 | clade: Clade = next(self.tree.find_clades(target_node_name)) 541 | descendent_nodes: list[Clade] = list(clade.find_clades()) 542 | x, y = [], [] 543 | for descendent_node in descendent_nodes: 544 | node_x, node_y = self.name2xy[str(descendent_node.name)] 545 | if descendent_node.is_terminal() and self._align_leaf_label: 546 | node_x = max(self.xlim) 547 | x.append(node_x) 548 | y.append(node_y) 549 | else: 550 | x, y = self.name2xy[target_node_name] 551 | target_node: Clade = next(self.tree.find_clades(target_node_name)) 552 | if target_node.is_terminal() and self._align_leaf_label: 553 | x = max(self.xlim) 554 | 555 | kwargs.setdefault("clip_on", False) 556 | kwargs.setdefault("zorder", 2.0) 557 | 558 | def plot_marker(ax: Axes): 559 | ax.scatter(x, y, s=size**2, marker=marker, **kwargs) # type: ignore 560 | 561 | self._plot_funcs.append(plot_marker) 562 | 563 | def link( 564 | self, 565 | query1: str | list[str] | tuple[str], 566 | query2: str | list[str] | tuple[str], 567 | *, 568 | pos1: str = "center", 569 | pos2: str = "center", 570 | color: str = "red", 571 | linestyle: str = "dashed", 572 | arrowstyle: str = "-|>", 573 | connectionstyle: str = "arc3,rad=0", 574 | **kwargs, 575 | ) -> None: 576 | """Plot link line between target nodes 577 | 578 | Parameters 579 | ---------- 580 | query1 : str | list[str] | tuple[str] 581 | Search query node name(s) for setting link start node. 582 | If multiple node names are set, 583 | MRCA(Most Recent Common Ancester) node is set. 584 | query2 : str | list[str] | tuple[str] 585 | Search query node name(s) for setting link end node. 586 | If multiple node names are set, 587 | MRCA(Most Recent Common Ancester) node is set. 588 | pos1 : str, optional 589 | Link start node branch position1 (`left`|`center`|`right`) 590 | pos2 : str, optional 591 | Link end node branch position2 (`left`|`center`|`right`) 592 | color : str, optional 593 | Link line color 594 | linestyle : str, optional 595 | Line line style (e.g. `dotted`, `dashdot`, `solid`) 596 | arrowstyle : str, optional 597 | Arrow style (e.g. `-`, `->`, `<->`, `<|-|>`) 598 | 599 | connectionstyle : str, optional 600 | Connection style (e.g. `arc3,rad=0.2`, `arc3,rad=-0.5`) 601 | 602 | **kwargs : dict, optional 603 | PathPatch properties (e.g. `lw=0.5, alpha=0.5, zorder=0, ...`) 604 | 605 | """ 606 | target_node_name1 = self._search_target_node_name(query1) 607 | target_node_name2 = self._search_target_node_name(query2) 608 | 609 | xy1 = self._get_target_pos_name2xy(pos1)[target_node_name1] 610 | xy2 = self._get_target_pos_name2xy(pos2)[target_node_name2] 611 | 612 | def plot_link(ax: Axes): 613 | ax.annotate( 614 | text="", 615 | xy=xy2, 616 | xytext=xy1, 617 | arrowprops=dict( 618 | color=color, 619 | ls=linestyle, 620 | arrowstyle=arrowstyle, 621 | connectionstyle=connectionstyle, 622 | **kwargs, 623 | ), 624 | ) 625 | 626 | self._plot_funcs.append(plot_link) 627 | 628 | def text_on_branch( 629 | self, 630 | query: str | list[str] | tuple[str], 631 | text: str, 632 | *, 633 | size: int = 8, 634 | xpos: str = "right", 635 | ypos: str = "top", 636 | xmargin_ratio: float = 0.01, 637 | ymargin_ratio: float = 0.05, 638 | **kwargs, 639 | ) -> None: 640 | """Plot text on branch of target node 641 | 642 | Parameters 643 | ---------- 644 | query : str | list[str] | tuple[str] 645 | Search query node name(s) for plotting text. If multiple node names are set, 646 | MRCA(Most Recent Common Ancester) node is set. 647 | text : str 648 | Text content 649 | size : int, optional 650 | Text size 651 | xpos : str, optional 652 | X position of plot text (`left`|`center`|`right`) 653 | ypos : str, optional 654 | Y position of plot text (`top`|`center`|`bottom`) 655 | xmargin_ratio : float, optional 656 | Text x margin ratio. If `xpos = center`, this param is ignored. 657 | ymargin_ratio : float, optional 658 | Text y margin ratio. If `ypos = center`, this param is ignored. 659 | **kwargs : dict, optional 660 | Text properties (e.g. `color="red", bbox=dict(color="skyblue"), ...`) 661 | 662 | """ 663 | # Set text ha & va by xpos & ypos 664 | if xpos not in ("left", "center", "right"): 665 | raise ValueError(f"{xpos=} is invalid ('left'|'center'|'right').") 666 | if ypos not in ("top", "center", "bottom"): 667 | raise ValueError(f"{ypos=} is invalid ('top'|'center'|'bottom').") 668 | ypos2va = dict(top="bottom", center="center", bottom="top") 669 | ha, va = xpos, ypos2va[ypos] 670 | 671 | if self._orientation == "left": 672 | xpos = dict(left="right", right="left", center="center")[xpos] 673 | 674 | # Get text plot target node & xy coordinate 675 | target_node_name = self._search_target_node_name(query) 676 | xpos2xy = dict( 677 | left=self.name2xy_left[target_node_name], 678 | center=self.name2xy_center[target_node_name], 679 | right=self.name2xy_right[target_node_name], 680 | ) 681 | x, y = xpos2xy[xpos] 682 | 683 | # Apply margin to x, y coordinate 684 | xmargin_size = self.max_tree_depth * xmargin_ratio 685 | if xpos == "left": 686 | x += xmargin_size 687 | elif xpos == "right": 688 | x -= xmargin_size 689 | ymargin_size = ymargin_ratio 690 | if ypos == "top": 691 | y += ymargin_size 692 | elif ypos == "bottom": 693 | y -= ymargin_size 694 | 695 | def plot_text(ax: Axes) -> None: 696 | # Plot text 697 | ax.text(x, y, s=text, size=size, va=va, ha=ha, **kwargs) 698 | 699 | self._plot_funcs.append(plot_text) 700 | 701 | def text_on_node( 702 | self, 703 | query: str | list[str] | tuple[str], 704 | text: str, 705 | *, 706 | size: int = 8, 707 | **kwargs, 708 | ) -> None: 709 | """Plot text on target node 710 | 711 | Parameters 712 | ---------- 713 | query : str | list[str] | tuple[str] 714 | Search query node name(s) for plotting text. If multiple node names are set, 715 | MRCA(Most Recent Common Ancester) node is set. 716 | text : str 717 | Text content 718 | size : int, optional 719 | Text size 720 | **kwargs : dict, optional 721 | Text properties (e.g. `color="red", bbox=dict(color="skyblue"), ...`) 722 | 723 | """ 724 | target_node_name = self._search_target_node_name(query) 725 | x, y = self.name2xy[target_node_name] 726 | 727 | kwargs.setdefault("va", "center_baseline") 728 | kwargs.setdefault("ha", "center") 729 | 730 | def plot_text(ax: Axes): 731 | ax.text(x, y, s=text, size=size, **kwargs) 732 | 733 | self._plot_funcs.append(plot_text) 734 | 735 | def set_node_label_props(self, target_node_label: str, **kwargs) -> None: 736 | """Set tree node label properties 737 | 738 | Parameters 739 | ---------- 740 | target_node_label : str 741 | Target node label name 742 | kwargs : dict, optional 743 | Text properties (e.g. `color="red", ...`) 744 | 745 | """ 746 | self._search_target_node_name(target_node_label) 747 | self._node2label_props[target_node_label] = kwargs 748 | 749 | def set_node_line_props( 750 | self, 751 | query: str | list[str] | tuple[str], 752 | *, 753 | descendent: bool = True, 754 | **kwargs, 755 | ) -> None: 756 | """Set tree node line properties 757 | 758 | Parameters 759 | ---------- 760 | query : str | list[str] | tuple[str] 761 | Search query node name(s) for coloring tree node line. 762 | If multiple node names are set, 763 | MRCA(Most Recent Common Ancester) node is set. 764 | descendent : bool, optional 765 | If True, set properties on target node's descendent as well. 766 | **kwargs : dict, optional 767 | Axes.plot properties (e.g. `color="blue", lw=2.0, ls="dashed", ...`) 768 | 769 | """ 770 | target_node_name = self._search_target_node_name(query) 771 | 772 | clade: Clade = next(self.tree.find_clades(target_node_name)) 773 | if descendent: 774 | descendent_nodes: list[Clade] = list(clade.find_clades()) 775 | for descendent_node in descendent_nodes: 776 | self._node2line_props[str(descendent_node.name)] = kwargs 777 | else: 778 | self._node2line_props[str(clade.name)] = kwargs 779 | 780 | def set_title( 781 | self, 782 | label: str, 783 | **kwargs, 784 | ) -> None: 785 | """Set title 786 | 787 | Parameters 788 | ---------- 789 | label : str 790 | Title text label 791 | **kwargs : dict, optional 792 | Axes.set_title properties (e.g. `size=12, color="red", ...`) 793 | 794 | """ 795 | 796 | def set_title(ax: Axes): 797 | ax.set_title(label, **kwargs) 798 | 799 | self._plot_funcs.append(set_title) 800 | 801 | def update_plot_props( 802 | self, 803 | *, 804 | tree_line_kws: dict[str, Any] | None = None, 805 | tree_align_line_kws: dict[str, Any] | None = None, 806 | ) -> None: 807 | """Update plot properties 808 | 809 | Parameters 810 | ---------- 811 | tree_line_kws : dict[str, Any], optional 812 | Axes.plot properties (e.g. `dict(color="red", lw=0.5, ...)`) 813 | By default, `color="black", lw=1, clip_on=False` are set. 814 | 815 | tree_align_line_kws : dict[str, Any], optional 816 | Axes.plot properties (e.g. `dict(color="red", ls="dashed", ...)`) 817 | By default, `lw=0.5, ls="dashed", alpha=0.5, clip_on=False` are set. 818 | 819 | """ 820 | tree_line_kws = {} if tree_line_kws is None else tree_line_kws 821 | tree_align_line_kws = {} if tree_align_line_kws is None else tree_align_line_kws 822 | 823 | self._tree_line_kws.update(tree_line_kws) 824 | self._tree_align_line_kws.update(tree_align_line_kws) 825 | 826 | def plotfig( 827 | self, 828 | *, 829 | dpi=100, 830 | ax: Axes | None = None, 831 | ) -> Figure: 832 | """Plot figure 833 | 834 | Parameters 835 | ---------- 836 | dpi : int, optional 837 | Figure DPI 838 | ax : Axes | None, optional 839 | Matplotlib axes for plotting. If None, figure & axes are newly created. 840 | 841 | Returns 842 | ------- 843 | figure : Figure 844 | Matplotlib figure 845 | """ 846 | # Initialize axes 847 | if ax is None: 848 | # Create matplotlib Figure & Axes 849 | fig, ax = self._init_figure(self.figsize, dpi=dpi) 850 | self._init_axes(ax) 851 | else: 852 | # Get matplotlib Figure & Axes 853 | self._init_axes(ax) 854 | fig: Figure = ax.get_figure() # type: ignore 855 | self._ax = ax 856 | 857 | # Plot tree line 858 | self._plot_tree_node_line(ax) 859 | # Plot node label 860 | self._plot_node_label(ax) 861 | # Plot all patches 862 | for patch in self._get_plot_patches(): 863 | ax.add_patch(patch) 864 | # Execute all plot functions 865 | for plot_func in self._get_plot_funcs(): 866 | plot_func(ax) 867 | 868 | return fig 869 | 870 | def savefig( 871 | self, 872 | savefile: str | Path, 873 | *, 874 | dpi: int = 100, 875 | pad_inches: float = 0.1, 876 | ) -> None: 877 | """Save figure to file 878 | 879 | `tv.savefig("result.png")` is alias for 880 | `tv.plotfig().savefig("result.png")` 881 | 882 | Parameters 883 | ---------- 884 | savefile : str | Path 885 | Save file (`*.png`|`*.jpg`|`*.svg`|`*.pdf`) 886 | dpi : int, optional 887 | DPI 888 | pad_inches : float, optional 889 | Padding inches 890 | """ 891 | fig = self.plotfig(dpi=dpi) 892 | fig.savefig( 893 | fname=savefile, # type: ignore 894 | dpi=dpi, 895 | pad_inches=pad_inches, 896 | bbox_inches="tight", 897 | ) 898 | # Clear & close figure to suppress memory leak 899 | fig.clear() 900 | plt.close(fig) 901 | 902 | ############################################################ 903 | # Private Method 904 | ############################################################ 905 | 906 | def _init_figure( 907 | self, 908 | figsize: tuple[float, float], 909 | dpi: int = 100, 910 | ) -> tuple[Figure, Axes]: 911 | """Initialize matplotlib figure 912 | 913 | Parameters 914 | ---------- 915 | figsize : tuple[float, float] 916 | Figure size 917 | dpi : int, optional 918 | Figure DPI 919 | 920 | Returns 921 | ------- 922 | figure, axes : tuple[Figure, Axes] 923 | Matplotlib Figure & Axes 924 | """ 925 | fig = plt.figure(figsize=figsize, dpi=dpi, layout="none") 926 | ax: Axes = fig.add_subplot() 927 | return fig, ax # type: ignore 928 | 929 | def _init_axes(self, ax: Axes) -> None: 930 | """Initialize matplotlib axes 931 | 932 | - xlim = (0, `root -> leaf max branch length`) 933 | - ylim = (0, `total tree leaf count + 1`) 934 | 935 | Parameters 936 | ---------- 937 | ax : Axes 938 | Matplotlib axes 939 | """ 940 | ax.set_xlim(*self.xlim) 941 | ax.set_ylim(*self.ylim) 942 | axis_pos2show = dict(bottom=False, top=False, left=False, right=False) 943 | for axis_pos, show in axis_pos2show.items(): 944 | ax.spines[axis_pos].set_visible(show) 945 | ax.tick_params(left=False, labelleft=False, bottom=False, labelbottom=False) 946 | 947 | def _get_plot_patches(self) -> list[Patch]: 948 | """Plot patches""" 949 | return deepcopy(self._plot_patches) 950 | 951 | def _get_plot_funcs(self) -> list[Callable[[Axes], None]]: 952 | """Plot functions""" 953 | return self._plot_funcs 954 | 955 | def _plot_tree_node_line(self, ax: Axes) -> None: 956 | """Plot tree line 957 | 958 | Parameters 959 | ---------- 960 | ax : Axes 961 | Matplotlib axes for plotting 962 | """ 963 | node: Clade 964 | child_node: Clade 965 | for node in self.tree.get_nonterminals(): 966 | parent_x, parent_y = self.name2xy[str(node.name)] 967 | for child_node in node.clades: 968 | child_x, child_y = self.name2xy[str(child_node.name)] 969 | _tree_line_kws = deepcopy(self._tree_line_kws) 970 | _tree_line_kws.update(self._node2line_props[str(child_node.name)]) 971 | # Plot vertical line 972 | v_line_points = (parent_x, parent_x), (parent_y, child_y) 973 | ax.plot(*v_line_points, **_tree_line_kws) 974 | # Plot horizontal line 975 | h_line_points = (parent_x, child_x), (child_y, child_y) 976 | ax.plot(*h_line_points, **_tree_line_kws) 977 | # Plot horizontal line for label alignment if required 978 | if child_node.is_terminal() and self._align_leaf_label: 979 | _tree_align_line_kws = deepcopy(self._tree_align_line_kws) 980 | _tree_align_line_kws.update(color=_tree_line_kws["color"]) 981 | h_line_points = (child_x, self.max_tree_depth), (child_y, child_y) 982 | ax.plot(*h_line_points, **_tree_align_line_kws) 983 | 984 | def _plot_node_label(self, ax: Axes) -> None: 985 | """Plot tree node label 986 | 987 | Parameters 988 | ---------- 989 | ax : Axes 990 | Matplotlib axes for plotting 991 | """ 992 | node: Clade 993 | for node in self.tree.find_clades(): 994 | # Get label x, y position 995 | x, y = self.name2xy[str(node.name)] 996 | # Get label size & xmargin 997 | if node.is_terminal(): 998 | label_size = self._leaf_label_size 999 | label_xmargin_ratio = self._leaf_label_xmargin_ratio 1000 | else: 1001 | label_size = self._innode_label_size 1002 | label_xmargin_ratio = self._innode_label_xmargin_ratio 1003 | label_xmargin = self.max_tree_depth * label_xmargin_ratio 1004 | # Set label x position with margin 1005 | if node.is_terminal() and self._align_leaf_label: 1006 | x = self.max_tree_depth + label_xmargin 1007 | else: 1008 | x += label_xmargin 1009 | # Skip if 'label is auto set name' or 'no label size' 1010 | if label_size <= 0: 1011 | continue 1012 | is_auto_innode_label = node.name in self._auto_innode_labels 1013 | if not self._show_auto_innode_label and is_auto_innode_label: 1014 | continue 1015 | # Plot label 1016 | text_kws = dict(size=label_size, ha="left", va="center_baseline") 1017 | text_kws.update(self._node2label_props[str(node.name)]) 1018 | if self._orientation == "left": 1019 | text_kws.update(ha="right") 1020 | ax.text(x, y, s=node.name, **text_kws) # type: ignore 1021 | 1022 | def _load_tree(self, data: str | Path | Tree, format: str) -> Tree: 1023 | """Load tree data 1024 | 1025 | Parameters 1026 | ---------- 1027 | data : str | Path | Tree 1028 | Tree data 1029 | format : str 1030 | Tree format 1031 | 1032 | Returns 1033 | ------- 1034 | tree : Tree 1035 | Tree object 1036 | """ 1037 | if isinstance(data, str) and urlparse(data).scheme in ("http", "https"): 1038 | # Load tree file from URL 1039 | return Phylo.read(io.StringIO(urlopen(data).read().decode()), format=format) 1040 | elif isinstance(data, (str, Path)) and os.path.isfile(data): 1041 | # Load tree file 1042 | return Phylo.read(data, format=format) 1043 | elif isinstance(data, str): 1044 | # Load tree string 1045 | return Phylo.read(io.StringIO(data), format=format) 1046 | elif isinstance(data, Tree): 1047 | return data 1048 | else: 1049 | raise ValueError(f"{data=} is invalid input tree data!!") 1050 | 1051 | def _set_uniq_innode_name(self, tree: Tree) -> tuple[Tree, list[str]]: 1052 | """Set unique internal node name (N_1, N_2, ..., N_XXX) 1053 | 1054 | Parameters 1055 | ---------- 1056 | tree : Tree 1057 | Tree object 1058 | 1059 | Returns 1060 | ------- 1061 | tree, uniq_node_names: tuple[Tree, list[str]] 1062 | Unique node name set tree object & set unique node names 1063 | """ 1064 | tree = deepcopy(tree) 1065 | uniq_innode_names: list[str] = [] 1066 | for idx, node in enumerate(tree.get_nonterminals(), 1): 1067 | uniq_innode_name = f"N_{idx}" 1068 | if node.name is None: 1069 | node.name = uniq_innode_name 1070 | uniq_innode_names.append(uniq_innode_name) 1071 | return tree, uniq_innode_names 1072 | 1073 | def _to_ultrametric_tree(self, tree: Tree) -> Tree: 1074 | """Convert to ultrametric tree 1075 | 1076 | Parameters 1077 | ---------- 1078 | tree : Tree 1079 | Tree 1080 | 1081 | Returns 1082 | ------- 1083 | tree : Tree 1084 | Ultrametric tree 1085 | """ 1086 | tree = deepcopy(tree) 1087 | # Get unit branch depth info 1088 | name2depth = {str(n.name): float(d) for n, d in tree.depths(True).items()} 1089 | name2depth = dict(sorted(name2depth.items(), key=lambda t: t[1], reverse=True)) 1090 | max_tree_depth = max(name2depth.values()) 1091 | # Reset node branch length 1092 | for node in tree.find_clades(): 1093 | node.branch_length = None 1094 | tree.root.branch_length = 0 1095 | # Calculate appropriate ultrametric tree branch length 1096 | for name, depth in name2depth.items(): 1097 | node = next(tree.find_clades(name)) 1098 | if not node.is_terminal(): 1099 | continue 1100 | path: list[Clade] | None = tree.get_path(node) 1101 | if path is None: 1102 | raise ValueError(f"{name=} node not exists?") 1103 | if depth == max_tree_depth: 1104 | for path_node in path: 1105 | path_node.branch_length = 1 1106 | else: 1107 | # Collect nodes info which has branch length 1108 | bl_sum, bl_exist_node_count = 0, 0 1109 | for path_node in path: 1110 | if path_node.branch_length is not None: 1111 | bl_sum += path_node.branch_length 1112 | bl_exist_node_count += 1 1113 | # Set branch length to no branch length nodes 1114 | other_bl = (max_tree_depth - bl_sum) / (len(path) - bl_exist_node_count) 1115 | for path_node in path: 1116 | if path_node.branch_length is None: 1117 | path_node.branch_length = other_bl 1118 | return tree 1119 | 1120 | def _check_node_name_dup(self, tree: Tree) -> None: 1121 | """Check node name duplication in tree 1122 | 1123 | Parameters 1124 | ---------- 1125 | tree : Tree 1126 | Tree object 1127 | """ 1128 | all_node_names = [str(n.name) for n in tree.find_clades()] 1129 | err_msg = "" 1130 | for node_name, count in Counter(all_node_names).items(): 1131 | if count > 1: 1132 | err_msg += f"{node_name=} is duplicated in tree ({count=}).\n" 1133 | if err_msg != "": 1134 | err_msg += "\nphyTreeViz cannot handle tree with duplicate node names!!" 1135 | raise ValueError("\n" + err_msg) 1136 | 1137 | def _search_target_node_name( 1138 | self, 1139 | query: str | list[str] | tuple[str], 1140 | ) -> str: 1141 | """Search target node name from query 1142 | 1143 | Parameters 1144 | ---------- 1145 | query : str | list[str] | tuple[str] 1146 | Search query node name(s). If multiple node names are set, 1147 | MRCA(Most Recent Common Ancester) node is set. 1148 | """ 1149 | self._check_node_name_exist(query) 1150 | if isinstance(query, (list, tuple)): 1151 | target_node_name = self.tree.common_ancestor(*query).name 1152 | else: 1153 | target_node_name = query 1154 | return target_node_name 1155 | 1156 | def _check_node_name_exist( 1157 | self, 1158 | query: str | list[str] | tuple[str], 1159 | ) -> None: 1160 | """Check node name exist in tree 1161 | 1162 | Parameters 1163 | ---------- 1164 | query : str | list[str] | tuple[str] 1165 | Query node name(s) for checking exist 1166 | """ 1167 | if isinstance(query, str): 1168 | query = [query] 1169 | err_msg = "" 1170 | for node_name in query: 1171 | if node_name not in self.all_node_labels: 1172 | err_msg += f"{node_name=} is not found in tree.\n" 1173 | if err_msg != "": 1174 | err_msg = f"\n{err_msg}\nAvailable node names:\n{self.all_node_labels}" 1175 | raise ValueError(err_msg) 1176 | 1177 | def _get_target_pos_name2xy( 1178 | self, 1179 | pos: str = "right", 1180 | ) -> dict[str, tuple[float, float]]: 1181 | """Get tree node name & xy coordinate dict in target position 1182 | 1183 | Parameters 1184 | ---------- 1185 | pos : str, optional 1186 | Target position (`left`|`center`|`right`) 1187 | 1188 | Returns 1189 | ------- 1190 | name2xy : dict[str, tuple[float, float]] 1191 | Tree node name & xy coordinate dict 1192 | """ 1193 | if pos not in ("left", "center", "right"): 1194 | raise ValueError(f"{pos=} is invalid ('left'|'center'|'right').") 1195 | 1196 | return dict( 1197 | left=self.name2xy_left, 1198 | center=self.name2xy_center, 1199 | right=self.name2xy_right, 1200 | )[pos] 1201 | 1202 | def _get_texts_rect( 1203 | self, 1204 | query: str | list[str] | tuple[str], 1205 | ) -> Rectangle: 1206 | """Get query label texts rectangle 1207 | 1208 | Parameters 1209 | ---------- 1210 | query : str | list[str] | tuple[str] 1211 | Search query node name(s) for rectangle. If multiple node names are set, 1212 | MRCA(Most Recent Common Ancester) node is set. 1213 | 1214 | Returns 1215 | ------- 1216 | rect : Rectangle 1217 | Label texts rectangle 1218 | """ 1219 | target_node_name = self._search_target_node_name(query) 1220 | node: Clade = next(self.tree.find_clades(target_node_name)) 1221 | target_labels = [str(n.name) for n in node.get_terminals()] 1222 | 1223 | target_texts: list[Text] = [ 1224 | t for t in self.ax.texts if t.get_text() in target_labels 1225 | ] 1226 | x_list: list[float] = [] 1227 | y_list: list[float] = [] 1228 | for text in target_texts: 1229 | bbox = text.get_window_extent() 1230 | trans_bbox = self.ax.transData.inverted().transform_bbox(bbox) 1231 | x_list.extend([trans_bbox.xmin, trans_bbox.xmax]) 1232 | y_list.extend([trans_bbox.ymin, trans_bbox.ymax]) 1233 | xmin, xmax = min(x_list), max(x_list) 1234 | ymin, ymax = min(y_list), max(y_list) 1235 | 1236 | return Rectangle(xy=(xmin, ymin), width=xmax - xmin, height=ymax - ymin) 1237 | 1238 | def _calc_name2xy_pos(self, pos: str = "center") -> dict[str, tuple[float, float]]: 1239 | """Calculate tree node name & xy coordinate 1240 | 1241 | Parameters 1242 | ---------- 1243 | pos : str, optional 1244 | Target xy position (`left`|`center`|`right`) 1245 | 1246 | Returns 1247 | ------- 1248 | name2xy_pos : dict[str, tuple[float, float]] 1249 | Tree node name & xy coordinate dict 1250 | """ 1251 | if pos not in ("left", "center", "right"): 1252 | raise ValueError(f"{pos=} is invalid ('left'|'center'|'right').") 1253 | 1254 | leaf_nodes = list(reversed(self.tree.get_terminals())) 1255 | if self._reverse: 1256 | leaf_nodes = list(reversed(leaf_nodes)) 1257 | 1258 | # Calculate right position xy coordinate 1259 | name2xy_right: dict[str, tuple[float, float]] = {} 1260 | node: Clade 1261 | for idx, node in enumerate(leaf_nodes, 1): 1262 | # Leaf node xy coordinates 1263 | name2xy_right[str(node.name)] = (self.tree.distance(node.name), idx) 1264 | for node in self.tree.get_nonterminals("postorder"): 1265 | # Internal node xy coordinates 1266 | x = self.tree.distance(node.name) 1267 | y = sum([name2xy_right[n.name][1] for n in node.clades]) / len(node.clades) 1268 | name2xy_right[str(node.name)] = (x, y) 1269 | if pos == "right": 1270 | return name2xy_right 1271 | 1272 | # Calculate left or center position xy coordinate 1273 | name2xy_pos: dict[str, tuple[float, float]] = {} 1274 | node: Clade 1275 | for node in self.tree.find_clades(): 1276 | node_name = str(node.name) 1277 | if node == self.tree.root: 1278 | name2xy_pos[node_name] = name2xy_right[node_name] 1279 | else: 1280 | tree_path = self.tree.get_path(node.name) 1281 | tree_path = [self.tree.root] + tree_path # type: ignore 1282 | parent_node: Clade = tree_path[-2] 1283 | parent_xy = self.name2xy_right[str(parent_node.name)] 1284 | if pos == "center": 1285 | x = (self.name2xy_right[node_name][0] + parent_xy[0]) / 2 1286 | elif pos == "left": 1287 | x = parent_xy[0] 1288 | else: 1289 | raise ValueError(f"{pos=} is invalid ('center' or 'left').") 1290 | y = self.name2xy_right[node_name][1] 1291 | name2xy_pos[node_name] = (x, y) 1292 | return name2xy_pos 1293 | 1294 | def _calc_name2rect(self) -> dict[str, Rectangle]: 1295 | """Calculate tree node name & rectangle for highlight 1296 | 1297 | Returns 1298 | ------- 1299 | name2rect : dict[str, Rectangle] 1300 | Tree node name & rectangle dict 1301 | """ 1302 | name2rect: dict[str, Rectangle] = {} 1303 | for name, xy in self.name2xy.items(): 1304 | # Get parent node 1305 | node: Clade = next(self.tree.find_clades(name)) 1306 | if node == self.tree.root: 1307 | parent_node = node 1308 | else: 1309 | tree_path = self.tree.get_path(node.name) 1310 | tree_path = [self.tree.root] + tree_path # type: ignore 1311 | parent_node: Clade = tree_path[-2] 1312 | 1313 | # Get child node xy coordinates 1314 | child_node_names = [str(n.name) for n in node.find_clades()] 1315 | x_list: list[float] = [] 1316 | y_list: list[float] = [] 1317 | for child_node_name in child_node_names: 1318 | x, y = self.name2xy[child_node_name] 1319 | x_list.append(x) 1320 | y_list.append(y) 1321 | 1322 | # Calculate rectangle min-max xy coordinate 1323 | parent_xy = self.name2xy[str(parent_node.name)] 1324 | min_x = (xy[0] + parent_xy[0]) / 2 1325 | max_x = self.max_tree_depth if self._align_leaf_label else max(x_list) 1326 | min_y = min(y_list) - 0.5 1327 | max_y = max(y_list) + 0.5 1328 | 1329 | # Set rectangle 1330 | rect = Rectangle( 1331 | xy=(min_x, min_y), 1332 | width=max_x - min_x, 1333 | height=max_y - min_y, 1334 | ) 1335 | name2rect[name] = rect 1336 | 1337 | return name2rect 1338 | --------------------------------------------------------------------------------