Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bump to v1.9.0 #82

Merged
merged 8 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/plot_api_example.ipynb

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions docs/plot_tips.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,5 @@ ignore = [
convention = "numpy"

[build-system]
requires = ["hatchling"]
requires = ["hatchling==1.26.3"]
build-backend = "hatchling.build"
2 changes: 1 addition & 1 deletion src/pycirclize/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pycirclize.circos import Circos

__version__ = "1.8.0"
__version__ = "1.9.0"

__all__ = [
"Circos",
Expand Down
128 changes: 121 additions & 7 deletions src/pycirclize/circos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import itertools
import math
import textwrap
import warnings
from collections import defaultdict
from collections.abc import Mapping
from copy import deepcopy
Expand All @@ -20,6 +21,9 @@
from matplotlib.figure import Figure
from matplotlib.patches import Patch
from matplotlib.projections.polar import PolarAxes
from matplotlib.text import Annotation, Text
from matplotlib.transforms import Bbox
from numpy.typing import NDArray

from pycirclize import config, utils
from pycirclize.parser import Bed, Matrix, RadarTable
Expand All @@ -38,12 +42,6 @@
class Circos:
"""Circos Visualization Class"""

# By default, after saving a figure using the `savefig()` method, figure object is
# automatically deleted to avoid memory leaks (no display on jupyter notebook)
# If you want to display the figure on jupyter notebook using `savefig()` method,
# set clear_savefig=False.
clear_savefig: bool = True

def __init__(
self,
sectors: Mapping[str, int | float | tuple[float, float]],
Expand Down Expand Up @@ -1063,6 +1061,10 @@ def plotfig(
for plot_func in self._get_all_plot_funcs():
plot_func(ax)

# Adjust annotation text position
if config.ann_adjust.enable:
self._adjust_annotation()

return fig # type: ignore

def savefig(
Expand Down Expand Up @@ -1099,7 +1101,7 @@ def savefig(
bbox_inches="tight",
)
# Clear & close figure to suppress memory leak
if self.clear_savefig:
if config.clear_savefig:
fig.clear()
plt.close(fig)

Expand Down Expand Up @@ -1224,3 +1226,115 @@ def _get_all_treeviz_list(self) -> list[TreeViz]:
All tree visualization instance list
"""
return list(itertools.chain(*[t._trees for t in self.tracks]))

def _adjust_annotation(self) -> None:
"""Adjust annotation text position"""
# Get sorted annotation list for position adjustment
ann_list = self._get_sorted_ann_list()
if len(ann_list) == 0 or config.ann_adjust.max_iter <= 0:
return
if len(ann_list) > config.ann_adjust.limit:
warn_msg = f"Too many annotations(={len(ann_list)}). Annotation position adjustment is not done." # noqa: E501
warnings.warn(warn_msg)
return

def get_ann_window_extent(ann: Annotation) -> Bbox:
return Text.get_window_extent(ann).expanded(*config.ann_adjust.expand)

# Iterate annotation position adjustment
self.ax.figure.draw_without_rendering() # type: ignore
ann2rad_shift_candidates = self._get_ann2rad_shift_candidates(ann_list)
for idx, ann in enumerate(ann_list[1:], 1):
orig_rad, orig_r = ann.xyann
ann_bbox = get_ann_window_extent(ann)
adj_ann_list = ann_list[:idx]
adj_ann_bboxes = [get_ann_window_extent(ann) for ann in adj_ann_list]

# Adjust radian position
iter, max_iter = 0, config.ann_adjust.max_iter
if utils.plot.is_ann_rad_shift_target_loc(orig_rad):
for rad_shift_candidate in ann2rad_shift_candidates[str(ann)]:
ann.xyann = (rad_shift_candidate, orig_r)
ann_bbox = get_ann_window_extent(ann)
if ann_bbox.count_overlaps(adj_ann_bboxes) == 0 or iter > max_iter:
break
else:
ann.xyann = (orig_rad, orig_r)
iter += 1

# Adjust radius position
while ann_bbox.count_overlaps(adj_ann_bboxes) > 0 and iter <= max_iter:
rad, r = ann.xyann
ann.xyann = (rad, r + config.ann_adjust.dr)
ann_bbox = get_ann_window_extent(ann)
iter += 1

# Plot annotation text bbox for developer check
# for ann in ann_list:
# utils.plot.plot_bbox(get_ann_window_extent(ann), self.ax)

def _get_sorted_ann_list(self) -> list[Annotation]:
"""Sorted annotation list

Sorting per 4 sections for adjusting annotation text position
"""
ann_list = [t for t in self.ax.texts if isinstance(t, Annotation)]
loc2ann_list: dict[str, list[Annotation]] = defaultdict(list)
for ann in ann_list:
loc = utils.plot.get_loc(ann.xyann[0])
loc2ann_list[loc].append(ann)

def sort_by_ann_rad(ann: Annotation):
return utils.plot.degrees(ann.xyann[0])

return (
sorted(loc2ann_list["upper-right"], key=sort_by_ann_rad, reverse=True)
+ sorted(loc2ann_list["lower-right"], key=sort_by_ann_rad, reverse=False)
+ sorted(loc2ann_list["lower-left"], key=sort_by_ann_rad, reverse=True)
+ sorted(loc2ann_list["upper-left"], key=sort_by_ann_rad, reverse=False)
)

def _get_ann2rad_shift_candidates(
self, ann_list: list[Annotation]
) -> dict[str, NDArray[np.float64]]:
"""Get candidate radian shift position of annotation text

Get the candidate radian position to shift of the target annotation
based on the radian positions of the previous and next annotations and
the maximum radian shift value.

Parameters
----------
ann_list : list[Annotation]
Annotation list

Returns
-------
ann2shift_rad_candidates : dict[str, NDArray[np.float64]]
Annotation & candidate radian shift position dict
"""
ann_list = sorted(ann_list, key=lambda a: utils.plot.degrees(a.xyann[0]))
ann2rad_shift_candidates: dict[str, NDArray[np.float64]] = {}
for idx, curr_ann in enumerate(ann_list):
# Get current, prev, next annotation info
curr_ann_rad = curr_ann.xyann[0]
prev_ann = curr_ann if idx == 0 else ann_list[idx - 1]
next_ann = curr_ann if idx == len(ann_list) - 1 else ann_list[idx + 1]
prev_ann_rad, next_ann_rad = prev_ann.xyann[0], next_ann.xyann[0]
# Get min-max radian shift position
if abs(curr_ann_rad - prev_ann_rad) > config.ann_adjust.max_rad_shift:
min_rad_shift = curr_ann_rad - config.ann_adjust.max_rad_shift
else:
min_rad_shift = prev_ann_rad
if abs(next_ann_rad - curr_ann_rad) > config.ann_adjust.max_rad_shift:
max_rad_shift = curr_ann_rad + config.ann_adjust.max_rad_shift
else:
max_rad_shift = next_ann_rad
# Calculate candidate radian positions between min-max radian shift position
# Sort candidate list in order of nearest to current annotation radian
drad = config.ann_adjust.drad
candidates = np.arange(min_rad_shift, max_rad_shift + drad, drad)
candidates = np.append(candidates, curr_ann_rad)
candidates = candidates[np.argsort(np.abs(candidates - curr_ann_rad))]
ann2rad_shift_candidates[str(curr_ann)] = candidates
return ann2rad_shift_candidates
36 changes: 36 additions & 0 deletions src/pycirclize/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import math
from enum import IntEnum
from typing import ClassVar

import matplotlib as mpl

Expand Down Expand Up @@ -44,6 +46,40 @@ class Direction(IntEnum):
BIDIRECTIONAL = 2


###########################################################
# Mutable Value Config (Mainly for Developer)
###########################################################


class _AnnotationAdjustConfig:
"""Annotation Position Adjustment Config"""

enable: ClassVar[bool] = True
"""Enable Annotation position adjustment (default: `True`)"""
limit: ClassVar[int] = 200
"""Limit of Annotation number for position adjustment (default: `200`)"""
max_iter: ClassVar[int] = 1000
"""Max iteration number for Annotation position adjustment (default: `1000`)"""
drad: ClassVar[float] = math.radians(0.1)
"""Delta radian for iterative position adjustment (default: `math.radians(0.1)`)"""
dr: ClassVar[float] = 0.1
"""Delta radius for iterative position adjustment (default: `0.1`)"""
expand: ClassVar[tuple[float, float]] = (1.2, 1.2)
"""Expand width & height factor of text bbox (default: `(1.2, 1.2)`)"""
max_rad_shift: ClassVar[float] = math.radians(3.0)
"""Max radian of Annotation position shift (default: `math.radians(3.0)`)"""


clear_savefig: bool = True
"""
By default, after saving a figure using the `savefig()` method, figure object is
automatically deleted to avoid memory leaks (no display on jupyter notebook)
If you want to display the figure on jupyter notebook using `savefig()` method,
set clear_savefig=False.
"""
ann_adjust = _AnnotationAdjustConfig


###########################################################
# Matplotlib Runtime Configuration
###########################################################
Expand Down
74 changes: 70 additions & 4 deletions src/pycirclize/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,72 @@ def arrow(
)
self._patches.append(arc_arrow)

def annotate(
self,
x: float,
label: str,
*,
min_r: float | None = None,
max_r: float | None = None,
label_size: float = 8,
shorten: int | None = 20,
line_kws: dict[str, Any] | None = None,
text_kws: dict[str, Any] | None = None,
) -> None:
"""Plot annotation label

The position of annotation labels is automatically adjusted so that there is
no overlap between them. The current algorithm for automatic adjustment of
overlap label positions is experimental and may be changed in the future.

Parameters
----------
x : float
X coordinate
label : str
Label
min_r : float | None, optional
Min radius position of annotation line. If None, `max(self.r_lim)` is set.
max_r : float | None, optional
Max radius position of annotation line. If None, `min_r + 5` is set.
label_size : float, optional
Label size
shorten : int | None, optional
Shorten label if int value is set.
line_kws : dict[str, Any] | None, optional
Patch properties (e.g. `dict(color="red", lw=1, ...)`)
<https://matplotlib.org/stable/api/_as_gen/matplotlib.patches.Patch.html>
text_kws : dict[str, Any] | None, optional
Text properties (e.g. `dict(color="red", alpha=0.5, ...)`)
<https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.text.html>
"""
line_kws = {} if line_kws is None else deepcopy(line_kws)
text_kws = {} if text_kws is None else deepcopy(text_kws)

if shorten:
label = label[:shorten] + "..." if len(label) > shorten else label

# Setup radian, radius coordinates
min_r = max(self.r_lim) if min_r is None else min_r
max_r = min_r + 5 if max_r is None else max_r
if min_r > max_r:
ValueError(f"{max_r=} must be larger than {min_r=}.")
rad = self.x_to_rad(x)
xy, xytext = (rad, min_r), (rad, max_r)

# Setup annotation line & text property
line_kws.setdefault("color", "grey")
line_kws.setdefault("lw", 0.5)
line_kws.update(dict(shrinkA=0, shrinkB=0, patchA=None, patchB=None))
line_kws.update(dict(arrowstyle="-", relpos=utils.plot.get_ann_relpos(rad)))
text_kws.update(utils.plot.get_label_params_by_rad(rad, "vertical"))
text_kws.update(dict(rotation=0, size=label_size))

def plot_annotate(ax: PolarAxes) -> None:
ax.annotate(label, xy, xytext, arrowprops=line_kws, **text_kws)

self._plot_funcs.append(plot_annotate)

def xticks(
self,
x: list[int] | list[float] | np.ndarray,
Expand Down Expand Up @@ -579,13 +645,13 @@ def yticks(
if side == "right":
x_lim = (self.end, self.end + x_tick_length)
x_text = self.end + (x_tick_length + x_label_margin)
deg_text = math.degrees(self.x_to_rad(x_text, True))
ha = "right" if utils.plot.is_lower_loc(deg_text) else "left"
rad_text = self.x_to_rad(x_text, True)
ha = "right" if utils.plot.is_lower_loc(rad_text) else "left"
elif side == "left":
x_lim = (self.start, self.start - x_tick_length)
x_text = self.start - (x_tick_length + x_label_margin)
deg_text = math.degrees(self.x_to_rad(x_text, True))
ha = "left" if utils.plot.is_lower_loc(deg_text) else "right"
rad_text = self.x_to_rad(x_text, True)
ha = "left" if utils.plot.is_lower_loc(rad_text) else "right"
else:
raise ValueError(f"{side=} is invalid ('right' or 'left').")
# Plot yticks
Expand Down
Loading
Loading