From 0e5e317b13a672e46e836ef9dfd04a3e22299cd6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:06 +1000 Subject: [PATCH 1/7] feat: add pycirclize plot wrappers --- ultraplot/axes/plot.py | 400 +++++++++++++++++++++++++- ultraplot/axes/plot_types/circlize.py | 310 ++++++++++++++++++++ 2 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 ultraplot/axes/plot_types/circlize.py diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index b24fd98c9..a68a00428 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -10,7 +10,7 @@ import sys from collections.abc import Callable, Iterable from numbers import Integral, Number -from typing import Any, Iterable, Optional, Union +from typing import Any, Iterable, Mapping, Optional, Sequence, Union import matplotlib as mpl import matplotlib.artist as martist @@ -205,6 +205,186 @@ """ docstring._snippet_manager["plot.curved_quiver"] = _curved_quiver_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 @@ -1849,6 +2029,224 @@ def curved_quiver( stream_container = CurvedQuiverSet(lc, ac) return stream_container + @docstring._snippet_manager + 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..a13a07eb0 --- /dev/null +++ b/ultraplot/axes/plot_types/circlize.py @@ -0,0 +1,310 @@ +#!/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 _ensure_polar(ax, label: str) -> None: + if not isinstance(ax, MplPolarAxes): + raise ValueError(f"{label} requires a polar axes (proj='polar').") + + +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). + """ + _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. + """ + _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. + """ + _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. + """ + _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). + """ + _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 From 76bac146338f6fa92859fa22cce4d7aed36b9402 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:20 +1000 Subject: [PATCH 2/7] build: add pycirclize optional extras --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0f7b6bc2e..61c909b25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,10 @@ dependencies= [ ] dynamic = ["version"] +[project.optional-dependencies] +circos = ["pycirclize>=1.10.1"] +all = ["pycirclize>=1.10.1"] + [project.urls] "Documentation" = "https://ultraplot.readthedocs.io" "Issue Tracker" = "https://github.com/ultraplot/ultraplot/issues" From f8ef74ad9ae3ae26dde2a26cfc08ade7a7021aa6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:35 +1000 Subject: [PATCH 3/7] test: add pycirclize wrapper smoke tests --- ultraplot/tests/test_plot.py | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 1bcb69684..47caf2fd7 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -722,6 +722,97 @@ def test_curved_quiver_color_and_cmap(rng, cmap): return fig +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, ax = uplt.subplots(proj="polar") + 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, ax = uplt.subplots(proj="polar") + 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, ax = uplt.subplots(proj="polar") + circos = ax.circos_bed(bed_path, plot=False) + assert len(circos.sectors) == 2 + circos.plotfig(ax=ax) + 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, ax = uplt.subplots(proj="polar") + circos = ax.circos({"A": 10, "B": 12}, plot=False) + assert len(circos.sectors) == 2 + circos.plotfig(ax=ax) + uplt.close(fig) + + def test_histogram_norms(): """ Check that all histograms-like plotting functions From 3ed75800018ba75ca6f7ae21e6685b91e3c155bc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:49 +1000 Subject: [PATCH 4/7] docs: add pycirclize plot type examples --- docs/examples/plot_types/07_radar.py | 32 +++++++++++++++++++ docs/examples/plot_types/08_chord_diagram.py | 21 +++++++++++++ docs/examples/plot_types/09_phylogeny.py | 15 +++++++++ docs/examples/plot_types/10_circos_bed.py | 33 ++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 docs/examples/plot_types/07_radar.py create mode 100644 docs/examples/plot_types/08_chord_diagram.py create mode 100644 docs/examples/plot_types/09_phylogeny.py create mode 100644 docs/examples/plot_types/10_circos_bed.py 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..bcfda3e8a --- /dev/null +++ b/docs/examples/plot_types/10_circos_bed.py @@ -0,0 +1,33 @@ +""" +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) + 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() From df84bd60241561c68eb1ca022a7bc5931d40b743 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:53:22 +1000 Subject: [PATCH 5/7] feat: integrate circos axes behavior --- ultraplot/axes/plot_types/circlize.py | 27 +++++++++++++++++++++------ ultraplot/figure.py | 8 +++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py index a13a07eb0..a98461f2e 100644 --- a/ultraplot/axes/plot_types/circlize.py +++ b/ultraplot/axes/plot_types/circlize.py @@ -39,9 +39,24 @@ def _import_pycirclize(): return pycirclize -def _ensure_polar(ax, label: str) -> None: +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]: @@ -100,7 +115,7 @@ def circos( """ Create a pyCirclize Circos instance (optionally plot immediately). """ - _ensure_polar(ax, "circos") + ax = _ensure_polar(ax, "circos") pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos( sectors, @@ -138,7 +153,7 @@ def chord_diagram( """ Render a chord diagram using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "chord_diagram") + 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) @@ -199,7 +214,7 @@ def radar_chart( """ Render a radar chart using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "radar_chart") + 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) @@ -255,7 +270,7 @@ def phylogeny( """ Render a phylogenetic tree using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "phylogeny") + ax = _ensure_polar(ax, "phylogeny") pycirclize = _import_pycirclize() if leaf_label_size is None: leaf_label_size = rc["font.size"] @@ -295,7 +310,7 @@ def circos_bed( """ Create a Circos instance from a BED file (optionally plot immediately). """ - _ensure_polar(ax, "circos_bed") + ax = _ensure_polar(ax, "circos_bed") pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos.initialize_from_bed( bed_file, diff --git a/ultraplot/figure.py b/ultraplot/figure.py index b2612d6a3..87028adb5 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1226,9 +1226,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] From a906b666e88b6f673ba54287c405c0fdd90371a3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:53:35 +1000 Subject: [PATCH 6/7] test: cover circos delegation and sharing --- ultraplot/tests/test_plot.py | 50 +++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 47caf2fd7..d86ceacbc 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -756,7 +756,8 @@ def test_chord_diagram_smoke(): index=["A", "B", "C"], columns=["A", "B", "C"], ) - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.chord_diagram(df, ticks_interval=None) assert hasattr(circos, "plotfig") uplt.close(fig) @@ -771,7 +772,8 @@ def test_phylogeny_smoke(): except ImportError: pytest.skip("pycirclize is not available") - fig, ax = uplt.subplots(proj="polar") + 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 @@ -790,7 +792,8 @@ def test_circos_bed_smoke(tmp_path): bed_path = tmp_path / "mini.bed" bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.circos_bed(bed_path, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) @@ -806,13 +809,52 @@ def test_circos_builder_smoke(): except ImportError: pytest.skip("pycirclize is not available") - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.circos({"A": 10, "B": 12}, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) 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 From b8be5574de685bd788dd46655d4aeca13260008e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 09:49:42 +1000 Subject: [PATCH 7/7] ci: add unit test coverage job --- .github/workflows/main.yml | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c035214ed..bcb70e58e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -192,9 +192,49 @@ jobs: test-mode: ${{ needs.select-tests.outputs.mode }} test-nodeids: ${{ needs.select-tests.outputs.tests }} + unit-tests: + name: Unit Tests (coverage focus) + needs: + - run-if-changes + if: always() && needs.run-if-changes.outputs.run == 'true' + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: mamba-org/setup-micromamba@v2.0.7 + with: + environment-file: ./environment.yml + init-shell: bash + create-args: >- + --verbose + python=3.11 + matplotlib=3.9 + cache-environment: true + cache-downloads: false + + - name: Build Ultraplot + run: | + pip install --no-build-isolation --no-deps . + + - name: Run unit tests + run: | + pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml -m "not mpl_image_compare" ultraplot/tests + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: Ultraplot/ultraplot + build-success: needs: - build + - unit-tests - run-if-changes if: always() runs-on: ubuntu-latest @@ -203,7 +243,7 @@ jobs: if [[ '${{ needs.run-if-changes.outputs.run }}' == 'false' ]]; then echo "No changes detected, tests skipped." else - if [[ '${{ needs.build.result }}' == 'success' ]]; then + if [[ '${{ needs.build.result }}' == 'success' && '${{ needs.unit-tests.result }}' == 'success' ]]; then echo "All tests passed successfully!" else echo "Tests failed!"