Skip to content

Commit

Permalink
Merge pull request #82 from moshi4/develop
Browse files Browse the repository at this point in the history
Bump to v1.9.0
  • Loading branch information
moshi4 authored Jan 25, 2025
2 parents 4b434c6 + 588e2e2 commit 50606e4
Show file tree
Hide file tree
Showing 9 changed files with 524 additions and 27 deletions.
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

0 comments on commit 50606e4

Please sign in to comment.