diff --git a/.readthedocs.yml b/.readthedocs.yml index c3cfa3e57..170bc3def 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -22,3 +22,5 @@ python: install: - method: pip path: . + extra_requirements: + - all diff --git a/README.rst b/README.rst index a92fc3dbf..d9526253e 100644 --- a/README.rst +++ b/README.rst @@ -104,6 +104,14 @@ UltraPlot is published on `PyPi `__ and pip install ultraplot conda install -c conda-forge ultraplot +The default install includes optional features (for example, pyCirclize-based plots). +For a minimal install, use ``--no-deps`` and install the core requirements: + +.. code-block:: bash + + pip install ultraplot --no-deps + pip install -r requirements-minimal.txt + Likewise, an existing installation of UltraPlot can be upgraded to the latest version with: diff --git a/docs/examples/plot_types/07_radar.py b/docs/examples/plot_types/07_radar.py new file mode 100644 index 000000000..c1b9627d7 --- /dev/null +++ b/docs/examples/plot_types/07_radar.py @@ -0,0 +1,32 @@ +""" +Radar chart +=========== + +UltraPlot wrapper around pyCirclize's radar chart helper. +""" + +import pandas as pd + +import ultraplot as uplt + +data = pd.DataFrame( + { + "Design": [3.5, 4.0], + "Speed": [4.2, 3.1], + "Reliability": [4.6, 4.1], + "Support": [3.2, 4.4], + }, + index=["Model A", "Model B"], +) + +fig, ax = uplt.subplots(proj="polar", refwidth=3.6) +ax.radar_chart( + data, + vmin=0, + vmax=5, + fill=True, + marker_size=4, + grid_interval_ratio=0.2, +) +ax.format(title="Product radar") +fig.show() diff --git a/docs/examples/plot_types/08_chord_diagram.py b/docs/examples/plot_types/08_chord_diagram.py new file mode 100644 index 000000000..4946c1b97 --- /dev/null +++ b/docs/examples/plot_types/08_chord_diagram.py @@ -0,0 +1,21 @@ +""" +Chord diagram +============= + +UltraPlot wrapper around pyCirclize chord diagrams. +""" + +import pandas as pd + +import ultraplot as uplt + +matrix = pd.DataFrame( + [[10, 6, 2], [6, 12, 4], [2, 4, 8]], + index=["A", "B", "C"], + columns=["A", "B", "C"], +) + +fig, ax = uplt.subplots(proj="polar", refwidth=3.6) +ax.chord_diagram(matrix, ticks_interval=None, space=4) +ax.format(title="Chord diagram") +fig.show() diff --git a/docs/examples/plot_types/09_phylogeny.py b/docs/examples/plot_types/09_phylogeny.py new file mode 100644 index 000000000..f1fa4a12b --- /dev/null +++ b/docs/examples/plot_types/09_phylogeny.py @@ -0,0 +1,15 @@ +""" +Phylogeny +========= + +UltraPlot wrapper around pyCirclize phylogeny plots. +""" + +import ultraplot as uplt + +newick = "((A,B),C);" + +fig, ax = uplt.subplots(proj="polar", refwidth=3.2) +ax.phylogeny(newick, leaf_label_size=10) +ax.format(title="Phylogeny") +fig.show() diff --git a/docs/examples/plot_types/10_circos_bed.py b/docs/examples/plot_types/10_circos_bed.py new file mode 100644 index 000000000..94bc0137c --- /dev/null +++ b/docs/examples/plot_types/10_circos_bed.py @@ -0,0 +1,34 @@ +""" +Circos from BED +=============== + +Build sectors from a BED file and render on UltraPlot polar axes. +""" + +import tempfile +from pathlib import Path + +import numpy as np + +import ultraplot as uplt + +bed_text = "chr1\t0\t100\nchr2\t0\t140\n" + +with tempfile.TemporaryDirectory() as tmpdir: + bed_path = Path(tmpdir) / "mini.bed" + bed_path.write_text(bed_text, encoding="utf-8") + + fig, ax = uplt.subplots(proj="polar", refwidth=3.6) + ax = ax[0] # pycirclize expects a PolarAxes, not a SubplotGrid wrapper + circos = ax.circos_bed(bed_path, plot=False) + + for sector in circos.sectors: + x = np.linspace(sector.start, sector.end, 8) + y = np.linspace(0, 50, 8) + track = sector.add_track((60, 90), r_pad_ratio=0.1) + track.axis() + track.line(x, y) + + circos.plotfig(ax=ax) + ax.format(title="BED sectors") + fig.show() diff --git a/environment.yml b/environment.yml index 904673230..5996f169f 100644 --- a/environment.yml +++ b/environment.yml @@ -35,3 +35,4 @@ dependencies: - lxml-html-clean - pip: - git+https://github.com/ultraplot/UltraTheme.git + - pycirclize diff --git a/pyproject.toml b/pyproject.toml index 9872f5853..730ba3209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ dependencies= [ "numpy>=1.26.0", "matplotlib>=3.9,<3.11", + "pycirclize>=1.10.1", "typing-extensions; python_version < '3.12'", ] dynamic = ["version"] diff --git a/requirements-minimal.txt b/requirements-minimal.txt new file mode 100644 index 000000000..c4fa01680 --- /dev/null +++ b/requirements-minimal.txt @@ -0,0 +1,3 @@ +numpy>=1.26.0 +matplotlib>=3.9,<3.11 +typing-extensions; python_version < "3.12" diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 0ff325691..f59eafa53 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -299,6 +299,185 @@ """ docstring._snippet_manager["plot.sankey"] = _sankey_docstring +_chord_docstring = """ +Draw a chord diagram using pyCirclize. + +Parameters +---------- +matrix : str, Path, pandas.DataFrame, or Matrix + Input matrix for the chord diagram. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +r_lim : 2-tuple of float, optional + Outer track radius limits (0 to 100). +cmap : str or dict, optional + Colormap name or name-to-color mapping for sectors and links. If omitted, + UltraPlot's color cycle is used. +link_cmap : list of (from, to, color), optional + Override link colors. +ticks_interval : int, optional + Tick interval for sector tracks. If None, no ticks are shown. +order : {'asc', 'desc'} or list, optional + Node ordering strategy or explicit node order. +label_kws, ticks_kws, link_kws : dict-like, optional + Keyword arguments passed to pyCirclize for labels, ticks, and links. +link_kws_handler : callable, optional + Callback to customize per-link keyword arguments. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.chord_diagram"] = _chord_docstring + +_radar_docstring = """ +Draw a radar chart using pyCirclize. + +Parameters +---------- +table : str, Path, pandas.DataFrame, or RadarTable + Input table for the radar chart. +r_lim : 2-tuple of float, optional + Radar chart radius limits (0 to 100). +vmin, vmax : float, optional + Value range for the radar chart. +fill : bool, optional + Whether to fill the radar polygons. +marker_size : int, optional + Marker size for radar points. +bg_color : color-spec or None, optional + Background fill color. +circular : bool, optional + Whether to draw circular grid lines. +cmap : str or dict, optional + Colormap name or row-name-to-color mapping. If omitted, UltraPlot's + color cycle is used. +show_grid_label : bool, optional + Whether to show radial grid labels. +grid_interval_ratio : float or None, optional + Grid interval ratio (0 to 1). +grid_line_kws, grid_label_kws : dict-like, optional + Keyword arguments passed to pyCirclize for grid lines and labels. +grid_label_formatter : callable, optional + Formatter for grid label values. +label_kws_handler, line_kws_handler, marker_kws_handler : callable, optional + Per-series styling callbacks passed to pyCirclize. + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.radar_chart"] = _radar_docstring + +_circos_docstring = """ +Create a Circos instance using pyCirclize. + +Parameters +---------- +sectors : mapping + Sector name and size (or range) mapping. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +sector2clockwise : dict, optional + Override clockwise settings per sector. +show_axis_for_debug : bool, optional + Show the polar axis for debug layout. +plot : bool, optional + If True, immediately render the circos figure on this axes. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.circos"] = _circos_docstring + +_phylogeny_docstring = """ +Draw a phylogenetic tree using pyCirclize. + +Parameters +---------- +tree_data : str, Path, or Tree + Tree data (file, URL, Tree object, or tree string). +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +r_lim : 2-tuple of float, optional + Tree track radius limits (0 to 100). +format : str, optional + Tree format (`newick`, `phyloxml`, `nexus`, `nexml`, `cdao`). +outer : bool, optional + If True, plot tree on the outer side. +align_leaf_label : bool, optional + If True, align leaf labels. +ignore_branch_length : bool, optional + Ignore branch lengths when plotting. +leaf_label_size : float, optional + Leaf label size. +leaf_label_rmargin : float, optional + Leaf label radius margin. +reverse : bool, optional + Reverse tree direction. +ladderize : bool, optional + Ladderize tree. +line_kws, align_line_kws : dict-like, optional + Keyword arguments for tree line styling. +label_formatter : callable, optional + Formatter for leaf labels. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos, pycirclize.TreeViz + The Circos instance and TreeViz helper. +""" + +docstring._snippet_manager["plot.phylogeny"] = _phylogeny_docstring + +_circos_bed_docstring = """ +Create a Circos instance from a BED file using pyCirclize. + +Parameters +---------- +bed_file : str or Path + BED file describing chromosome ranges. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +sector2clockwise : dict, optional + Override clockwise settings per sector. +plot : bool, optional + If True, immediately render the circos figure on this axes. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.circos_bed"] = _circos_bed_docstring # Auto colorbar and legend docstring _guide_docstring = """ colorbar : bool, int, or str, optional @@ -2125,6 +2304,440 @@ def _looks_like_links(values): diagrams = sankey.finish() return diagrams[0] if len(diagrams) == 1 else diagrams + def circos( + self, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos)s + """ + from .plot_types.circlize import circos + + return circos( + self, + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + plot=plot, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def phylogeny( + self, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter: Callable[[str], str] | None = None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, + ): + """ + %(plot.phylogeny)s + """ + from .plot_types.circlize import phylogeny + + return phylogeny( + self, + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=line_kws, + label_formatter=label_formatter, + align_line_kws=align_line_kws, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def circos_bed( + self, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos_bed)s + """ + from .plot_types.circlize import circos_bed + + return circos_bed( + self, + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + plot=plot, + tooltip=tooltip, + ) + + def bed(self, *args, **kwargs): + """ + Alias for `~PlotAxes.circos_bed`. + """ + return self.circos_bed(*args, **kwargs) + + @docstring._snippet_manager + def chord_diagram( + self, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler: Callable[[str, str], Mapping[str, Any] | None] | None = None, + tooltip: bool = False, + ): + """ + %(plot.chord_diagram)s + """ + from .plot_types.circlize import chord_diagram + + return chord_diagram( + self, + matrix, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + tooltip=tooltip, + ) + + def chord(self, *args, **kwargs): + """ + Alias for `~PlotAxes.chord_diagram`. + """ + return self.chord_diagram(*args, **kwargs) + + @docstring._snippet_manager + def radar_chart( + self, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter: Callable[[float], str] | None = None, + label_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + line_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + marker_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + ): + """ + %(plot.radar_chart)s + """ + from .plot_types.circlize import radar_chart + + return radar_chart( + self, + table, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + + def radar(self, *args, **kwargs): + """ + Alias for `~PlotAxes.radar_chart`. + """ + return self.radar_chart(*args, **kwargs) + + def circos( + self, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos)s + """ + from .plot_types.circlize import circos + + return circos( + self, + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + plot=plot, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def phylogeny( + self, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter: Callable[[str], str] | None = None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, + ): + """ + %(plot.phylogeny)s + """ + from .plot_types.circlize import phylogeny + + return phylogeny( + self, + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=line_kws, + label_formatter=label_formatter, + align_line_kws=align_line_kws, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def circos_bed( + self, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos_bed)s + """ + from .plot_types.circlize import circos_bed + + return circos_bed( + self, + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + plot=plot, + tooltip=tooltip, + ) + + def bed(self, *args, **kwargs): + """ + Alias for `~PlotAxes.circos_bed`. + """ + return self.circos_bed(*args, **kwargs) + + @docstring._snippet_manager + def chord_diagram( + self, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler: Callable[[str, str], Mapping[str, Any] | None] | None = None, + tooltip: bool = False, + ): + """ + %(plot.chord_diagram)s + """ + from .plot_types.circlize import chord_diagram + + return chord_diagram( + self, + matrix, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + tooltip=tooltip, + ) + + def chord(self, *args, **kwargs): + """ + Alias for `~PlotAxes.chord_diagram`. + """ + return self.chord_diagram(*args, **kwargs) + + @docstring._snippet_manager + def radar_chart( + self, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter: Callable[[float], str] | None = None, + label_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + line_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + marker_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + ): + """ + %(plot.radar_chart)s + """ + from .plot_types.circlize import radar_chart + + return radar_chart( + self, + table, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + + def radar(self, *args, **kwargs): + """ + Alias for `~PlotAxes.radar_chart`. + """ + return self.radar_chart(*args, **kwargs) + def _call_native(self, name, *args, **kwargs): """ Call the plotting method and redirect internal calls to native methods. diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py new file mode 100644 index 000000000..a98461f2e --- /dev/null +++ b/ultraplot/axes/plot_types/circlize.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +Helpers for pyCirclize-backed circular plots. +""" +from __future__ import annotations + +import itertools +import sys +from pathlib import Path +from typing import Any, Mapping, Sequence + +from matplotlib.projections.polar import PolarAxes as MplPolarAxes + +from ... import constructor +from ...config import rc + + +def _import_pycirclize(): + try: + import pycirclize + except ImportError as exc: + base = Path(__file__).resolve().parents[3] / "pyCirclize" / "src" + if base.is_dir() and str(base) not in sys.path: + sys.path.insert(0, str(base)) + try: + import pycirclize + except ImportError as exc2: + raise ImportError( + "pycirclize is required for circos plots. Install it with " + "`pip install 'ultraplot[circos]'` or ensure " + "`pyCirclize/src` is on PYTHONPATH." + ) from exc2 + else: + raise ImportError( + "pycirclize is required for circos plots. Install it with " + "`pip install 'ultraplot[circos]'` or ensure " + "`pyCirclize/src` is on PYTHONPATH." + ) from exc + return pycirclize + + +def _unwrap_axes(ax, label: str): + if ax.__class__.__name__ == "SubplotGrid": + if len(ax) != 1: + raise ValueError(f"{label} expects a single axes, got {len(ax)}.") + ax = ax[0] + return ax + + +def _ensure_polar(ax, label: str): + ax = _unwrap_axes(ax, label) + if not isinstance(ax, MplPolarAxes): + raise ValueError(f"{label} requires a polar axes (proj='polar').") + if getattr(ax, "_sharex", None) is not None: + ax._unshare(which="x") + if getattr(ax, "_sharey", None) is not None: + ax._unshare(which="y") + ax._ultraplot_axis_type = ("circos", type(ax)) + return ax + + +def _cycle_colors(n: int) -> list[str]: + cycle = constructor.Cycle(rc["cycle"]) + colors = list(cycle.by_key().get("color", [])) + if not colors: + colors = ["0.2"] + if len(colors) >= n: + return colors[:n] + return [color for _, color in zip(range(n), itertools.cycle(colors))] + + +def _resolve_chord_defaults(matrix: Any, cmap: Any): + pycirclize = _import_pycirclize() + from pycirclize.parser.matrix import Matrix + + if isinstance(matrix, Matrix): + matrix_obj = matrix + else: + matrix_obj = Matrix(matrix) + + if cmap is None: + names = matrix_obj.all_names + cmap = dict(zip(names, _cycle_colors(len(names)), strict=True)) + return pycirclize, matrix_obj, cmap + + +def _resolve_radar_defaults(table: Any, cmap: Any): + pycirclize = _import_pycirclize() + from pycirclize.parser.table import RadarTable + + if isinstance(table, RadarTable): + table_obj = table + else: + table_obj = RadarTable(table) + + if cmap is None: + names = table_obj.row_names + cmap = dict(zip(names, _cycle_colors(len(names)), strict=True)) + return pycirclize, table_obj, cmap + + +def circos( + ax, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, +): + """ + Create a pyCirclize Circos instance (optionally plot immediately). + """ + ax = _ensure_polar(ax, "circos") + pycirclize = _import_pycirclize() + circos_obj = pycirclize.Circos( + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + ) + if plot: + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj + + +def chord_diagram( + ax, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler=None, + tooltip: bool = False, +): + """ + Render a chord diagram using pyCirclize on the provided polar axes. + """ + ax = _ensure_polar(ax, "chord_diagram") + + pycirclize, matrix_obj, cmap = _resolve_chord_defaults(matrix, cmap) + label_kws = {} if label_kws is None else dict(label_kws) + ticks_kws = {} if ticks_kws is None else dict(ticks_kws) + + label_kws.setdefault("size", rc["font.size"]) + label_kws.setdefault("color", rc["meta.color"]) + ticks_kws.setdefault("label_size", rc["font.size"]) + text_kws = ticks_kws.get("text_kws") + if text_kws is None: + ticks_kws["text_kws"] = {"color": rc["meta.color"]} + else: + text_kws = dict(text_kws) + text_kws.setdefault("color", rc["meta.color"]) + ticks_kws["text_kws"] = text_kws + + circos = pycirclize.Circos.chord_diagram( + matrix_obj, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + ) + circos.plotfig(ax=ax, tooltip=tooltip) + return circos + + +def radar_chart( + ax, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter=None, + label_kws_handler=None, + line_kws_handler=None, + marker_kws_handler=None, +): + """ + Render a radar chart using pyCirclize on the provided polar axes. + """ + ax = _ensure_polar(ax, "radar_chart") + + pycirclize, table_obj, cmap = _resolve_radar_defaults(table, cmap) + grid_line_kws = {} if grid_line_kws is None else dict(grid_line_kws) + grid_label_kws = {} if grid_label_kws is None else dict(grid_label_kws) + + grid_line_kws.setdefault("color", rc["grid.color"]) + grid_label_kws.setdefault("size", rc["font.size"]) + grid_label_kws.setdefault("color", rc["meta.color"]) + + circos = pycirclize.Circos.radar_chart( + table_obj, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + circos.plotfig(ax=ax) + return circos + + +def phylogeny( + ax, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter=None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, +): + """ + Render a phylogenetic tree using pyCirclize on the provided polar axes. + """ + ax = _ensure_polar(ax, "phylogeny") + pycirclize = _import_pycirclize() + if leaf_label_size is None: + leaf_label_size = rc["font.size"] + circos_obj, treeviz = pycirclize.Circos.initialize_from_tree( + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=None if line_kws is None else dict(line_kws), + label_formatter=label_formatter, + align_line_kws=None if align_line_kws is None else dict(align_line_kws), + ) + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj, treeviz + + +def circos_bed( + ax, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, +): + """ + Create a Circos instance from a BED file (optionally plot immediately). + """ + ax = _ensure_polar(ax, "circos_bed") + pycirclize = _import_pycirclize() + circos_obj = pycirclize.Circos.initialize_from_bed( + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + ) + if plot: + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 01d449d36..1ec804241 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1259,9 +1259,11 @@ def _get_border_axes( xspan = xright - xleft + 1 yspan = yright - yleft + 1 number = axi.number - axis_type = type(axi) - if isinstance(axi, (paxes.GeoAxes)): - axis_type = axi.projection + axis_type = getattr(axi, "_ultraplot_axis_type", None) + if axis_type is None: + axis_type = type(axi) + if isinstance(axi, (paxes.GeoAxes)): + axis_type = axi.projection if axis_type not in seen_axis_type: seen_axis_type[axis_type] = len(seen_axis_type) type_number = seen_axis_type[axis_type] diff --git a/ultraplot/tests/test_circlize_integration.py b/ultraplot/tests/test_circlize_integration.py new file mode 100644 index 000000000..a8690fa0d --- /dev/null +++ b/ultraplot/tests/test_circlize_integration.py @@ -0,0 +1,213 @@ +import builtins +import sys +import types +from pathlib import Path + +import pytest + +import ultraplot as uplt +from ultraplot import rc +from ultraplot.axes.plot_types import circlize as circlize_mod + + +@pytest.fixture() +def fake_pycirclize(monkeypatch): + class DummyCircos: + def __init__(self, sectors=None, **kwargs): + self.sectors = sectors + self.kwargs = kwargs + self.plot_called = False + self.plot_kwargs = None + + def plotfig(self, *args, **kwargs): + self.plot_called = True + self.plot_kwargs = kwargs + + @classmethod + def chord_diagram(cls, matrix_obj, **kwargs): + obj = cls({"matrix": True}) + obj.matrix_obj = matrix_obj + obj.kwargs = kwargs + return obj + + @classmethod + def radar_chart(cls, table_obj, **kwargs): + obj = cls({"table": True}) + obj.table_obj = table_obj + obj.kwargs = kwargs + return obj + + @classmethod + def initialize_from_tree(cls, *args, **kwargs): + obj = cls({"tree": True}) + obj.kwargs = kwargs + return obj, {"treeviz": True} + + @classmethod + def initialize_from_bed(cls, *args, **kwargs): + obj = cls({"bed": True}) + obj.kwargs = kwargs + return obj + + class DummyMatrix: + def __init__(self, data): + self.data = data + if isinstance(data, dict): + self.all_names = list(data.keys()) + else: + self.all_names = ["A", "B"] + + class DummyRadarTable: + def __init__(self, data): + self.data = data + if isinstance(data, dict): + self.row_names = list(data.keys()) + else: + self.row_names = ["A", "B"] + + pycirclize = types.ModuleType("pycirclize") + pycirclize.__path__ = [] + pycirclize.Circos = DummyCircos + parser = types.ModuleType("pycirclize.parser") + parser.__path__ = [] + matrix = types.ModuleType("pycirclize.parser.matrix") + table = types.ModuleType("pycirclize.parser.table") + matrix.Matrix = DummyMatrix + table.RadarTable = DummyRadarTable + parser.matrix = matrix + parser.table = table + pycirclize.parser = parser + + monkeypatch.setitem(sys.modules, "pycirclize", pycirclize) + monkeypatch.setitem(sys.modules, "pycirclize.parser", parser) + monkeypatch.setitem(sys.modules, "pycirclize.parser.matrix", matrix) + monkeypatch.setitem(sys.modules, "pycirclize.parser.table", table) + + yield pycirclize + + for name in ( + "pycirclize", + "pycirclize.parser", + "pycirclize.parser.matrix", + "pycirclize.parser.table", + ): + sys.modules.pop(name, None) + + +def test_circos_requires_polar_axes(): + fig, ax = uplt.subplots() + with pytest.raises(ValueError, match="requires a polar axes"): + ax.circos({"A": 1}) + uplt.close(fig) + + +def test_circos_delegates_grid(fake_pycirclize): + fig, axs = uplt.subplots(ncols=2, proj="polar") + result = axs.circos({"A": 1}, plot=False) + assert isinstance(result, tuple) + assert len(result) == 2 + assert all(hasattr(circos, "sectors") for circos in result) + uplt.close(fig) + + +def test_chord_diagram_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + matrix = {"A": {"B": 1}, "B": {"A": 2}} + circos = ax.chord_diagram(matrix) + assert circos.plot_called is True + assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + label_kws = circos.kwargs["label_kws"] + ticks_kws = circos.kwargs["ticks_kws"] + assert label_kws["color"] == rc["meta.color"] + assert label_kws["size"] == rc["font.size"] + assert ticks_kws["label_size"] == rc["font.size"] + assert ticks_kws["text_kws"]["color"] == rc["meta.color"] + uplt.close(fig) + + +def test_radar_chart_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + table = {"A": [1, 2], "B": [3, 4]} + circos = ax.radar_chart(table, vmin=0, vmax=4, fill=False) + assert circos.plot_called is True + assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + assert circos.kwargs["grid_line_kws"]["color"] == rc["grid.color"] + assert circos.kwargs["grid_label_kws"]["color"] == rc["meta.color"] + assert circos.kwargs["grid_label_kws"]["size"] == rc["font.size"] + uplt.close(fig) + + +def test_phylogeny_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + circos, treeviz = ax.phylogeny("((A,B),C);") + assert circos.plot_called is True + assert treeviz["treeviz"] is True + assert circos.kwargs["leaf_label_size"] == rc["font.size"] + uplt.close(fig) + + +def test_circos_plot_and_tooltip(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos({"A": 1, "B": 2}, plot=True, tooltip=True) + assert circos.plot_called is True + assert circos.plot_kwargs["tooltip"] is True + uplt.close(fig) + + +def test_circos_bed_plot_toggle(fake_pycirclize, tmp_path): + bed_path = tmp_path / "tiny.bed" + bed_path.write_text("chr1\t0\t10\n", encoding="utf-8") + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos_bed(bed_path, plot=False) + assert circos.plot_called is False + circos = ax.circos_bed(bed_path, plot=True, tooltip=True) + assert circos.plot_called is True + assert circos.plot_kwargs["tooltip"] is True + uplt.close(fig) + + +def test_import_pycirclize_error_message(monkeypatch): + orig_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "pycirclize": + raise ImportError("boom") + return orig_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr(Path, "is_dir", lambda self: False) + sys.modules.pop("pycirclize", None) + with pytest.raises(ImportError, match="pycirclize is required for circos plots"): + circlize_mod._import_pycirclize() + + +def test_resolve_defaults_with_existing_objects(fake_pycirclize): + matrix_mod = sys.modules["pycirclize.parser.matrix"] + table_mod = sys.modules["pycirclize.parser.table"] + matrix = matrix_mod.Matrix({"A": {"B": 1}, "B": {"A": 2}}) + table = table_mod.RadarTable({"A": [1, 2], "B": [3, 4]}) + + _, matrix_obj, cmap = circlize_mod._resolve_chord_defaults(matrix, cmap=None) + assert matrix_obj is matrix + assert set(cmap.keys()) == {"A", "B"} + + _, table_obj, cmap = circlize_mod._resolve_radar_defaults(table, cmap=None) + assert table_obj is table + assert set(cmap.keys()) == {"A", "B"} + + +def test_alias_methods(fake_pycirclize, tmp_path): + fig, ax = uplt.subplots(proj="polar") + matrix = {"A": {"B": 1}, "B": {"A": 2}} + circos = ax.chord(matrix) + assert circos.plot_called is True + + table = {"A": [1, 2], "B": [3, 4]} + circos = ax.radar(table, vmin=0, vmax=4, fill=False) + assert circos.plot_called is True + + bed_path = tmp_path / "mini.bed" + bed_path.write_text("chr1\t0\t10\n", encoding="utf-8") + circos = ax.bed(bed_path, plot=False) + assert hasattr(circos, "sectors") + uplt.close(fig) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 92025e872..6cafa1373 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1088,6 +1088,137 @@ def test_sankey_flow_label_text_callable(): assert text == "1.2" +def test_radar_chart_smoke(): + """Smoke test for pyCirclize radar chart wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + import pandas as pd + + df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [2, 1]}, index=["set1", "set2"]) + fig, ax = uplt.subplots(proj="polar") + circos = ax.radar_chart(df, vmin=0, vmax=4, fill=False, marker_size=3) + assert hasattr(circos, "plotfig") + uplt.close(fig) + + +def test_chord_diagram_smoke(): + """Smoke test for pyCirclize chord diagram wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + import pandas as pd + + df = pd.DataFrame( + [[5, 2, 1], [2, 6, 3], [1, 3, 4]], + index=["A", "B", "C"], + columns=["A", "B", "C"], + ) + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + circos = ax.chord_diagram(df, ticks_interval=None) + assert hasattr(circos, "plotfig") + uplt.close(fig) + + +def test_phylogeny_smoke(): + """Smoke test for pyCirclize phylogeny wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + circos, treeviz = ax.phylogeny("((A,B),C);", leaf_label_size=8) + assert hasattr(circos, "plotfig") + assert treeviz is not None + uplt.close(fig) + + +def test_circos_bed_smoke(tmp_path): + """Smoke test for BED-based circlize wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + bed_path = tmp_path / "mini.bed" + bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") + + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + circos = ax.circos_bed(bed_path, plot=True) + assert len(circos.sectors) == 2 + uplt.close(fig) + + +def test_circos_builder_smoke(): + """Smoke test for general Circos wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] + circos = ax.circos({"A": 10, "B": 12}, plot=True) + assert len(circos.sectors) == 2 + uplt.close(fig) + + +def test_circos_unshares_axes(): + """Circos wrappers should unshare axes if they were shared.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(ncols=2, proj="polar", share="all") + ax = axs[0] + x_siblings = list(ax._shared_axes["x"].get_siblings(ax)) + y_siblings = list(ax._shared_axes["y"].get_siblings(ax)) + if len(x_siblings) == 1 and len(y_siblings) == 1: + pytest.skip("polar axes are not shared in this configuration") + ax.circos({"A": 10, "B": 12}, plot=False) + x_siblings = list(ax._shared_axes["x"].get_siblings(ax)) + y_siblings = list(ax._shared_axes["y"].get_siblings(ax)) + assert len(x_siblings) == 1 + assert len(y_siblings) == 1 + uplt.close(fig) + + +def test_circos_delegation_subplots(): + """SubplotGrid should delegate circos calls for singleton grids.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(proj="polar") + circos = axs.circos({"A": 10, "B": 12}, plot=False) + assert len(circos.sectors) == 2 + uplt.close(fig) + + def test_histogram_norms(): """ Check that all histograms-like plotting functions