Skip to content

Instantly share code, notes, and snippets.

@Pocket-titan
Created April 14, 2024 16:59
Show Gist options
  • Save Pocket-titan/a4f8c7c0531e4b27386cae3039f9d105 to your computer and use it in GitHub Desktop.
Save Pocket-titan/a4f8c7c0531e4b27386cae3039f9d105 to your computer and use it in GitHub Desktop.
Improved 2d angle annotation for matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Arc
from matplotlib.transforms import Bbox, IdentityTransform, TransformedBbox
class AngleAnnotation(Arc):
"""
Draws an arc between two vectors which appears circular in display space.
"""
def __init__(
self,
xy,
p1,
p2,
size=75,
unit="points",
ax=None,
text=None,
textposition="inside",
text_kw=None,
**kwargs,
):
"""
Parameters
----------
xy, p1, p2 : tuple or array of two floats
Center position and two points. Angle annotation is drawn between
the two vectors connecting *p1* and *p2* with *xy*, respectively.
Units are data coordinates.
size : float
Diameter of the angle annotation in units specified by *unit*.
unit : str
One of the following strings to specify the unit of *size*:
* "pixels": pixels
* "points": points, use points instead of pixels to not have a dependence on the DPI
* "axes width", "axes height": relative units of Axes width, height
* "axes min", "axes max": minimum or maximum of relative Axes width, height
* "data width", "data height": relative units of data width, height
* "data min", "data max": minimum or maximum of relative data width, height
ax : `matplotlib.axes.Axes`
The Axes to add the angle annotation to.
text : str
The text to mark the angle with.
textposition : {"inside", "outside", "edge"}
Whether to show the text in- or outside the arc. "edge" can be used
for custom positions anchored at the arc's edge.
text_kw : dict
Dictionary of arguments passed to the Annotation.
**kwargs
Further parameters are passed to `matplotlib.patches.Arc`. Use this
to specify, color, linewidth etc. of the arc.
"""
self.ax = ax or plt.gca()
self._xydata = xy # in data coordinates
self.vec1 = p1
self.vec2 = p2
self.size = size
self.unit = unit
self.textposition = textposition
super().__init__(
self._xydata,
size,
size,
angle=0.0,
theta1=self.theta1,
theta2=self.theta2,
**kwargs,
)
self.set_transform(IdentityTransform())
self.ax.add_patch(self)
if text is not None:
self.kw = dict(
ha="center",
va="center",
xycoords=IdentityTransform(),
xytext=(0, 0),
textcoords="offset points",
annotation_clip=True,
)
self.kw.update(text_kw or {})
self.text = self.ax.annotate(text, xy=self._center, **self.kw)
if self.unit[:4] == "data":
self.ax.get_figure().draw_without_rendering()
self.update_arc()
def transform_vec(self, vec):
return self.ax.transData.transform(vec) - self._center
def update_arc(self):
_size = self.get_size()
self._width = _size
self._height = _size
def get_size(self):
if self.unit == "pixels":
factor = 1.0
if self.unit == "points":
factor = self.ax.figure.dpi / 72.0
else:
if self.unit[:4] == "axes":
b = TransformedBbox(Bbox.unit(), self.ax.transAxes)
if self.unit[:4] == "data":
b = TransformedBbox(Bbox.unit(), self.ax.transData)
dic = {
"max": max(b.width, b.height),
"min": min(b.width, b.height),
"width": b.width,
"height": b.height,
}
factor = dic[self.unit[5:]]
return self.size * factor
def set_size(self, size):
self.size = size
def get_center_in_pixels(self):
"""return center in pixels"""
return self.ax.transData.transform(self._xydata)
def set_center(self, xy):
"""set center in data coordinates"""
self._xydata = xy
def get_theta(self, vec):
vec_in_pixels = self.transform_vec(vec)
return np.rad2deg(np.arctan2(vec_in_pixels[1], vec_in_pixels[0]))
def get_theta1(self):
return self.get_theta(self.vec1)
def get_theta2(self):
return self.get_theta(self.vec2)
def set_theta(self, angle):
pass
# Redefine attributes of the Arc to always give values in pixel space
_center = property(get_center_in_pixels, set_center)
theta1 = property(get_theta1, set_theta)
theta2 = property(get_theta2, set_theta)
width = property(get_size, set_size)
height = property(get_size, set_size)
# The following two methods are needed to update the text position.
def draw(self, renderer):
self.update_text()
self.update_arc()
super().draw(renderer)
def update_text(self):
if not hasattr(self, "text") or self.text is None:
return
c = self._center
s = self.get_size()
angle_span = (self.theta2 - self.theta1) % 360
angle = np.deg2rad(self.theta1 + angle_span / 2)
if self.textposition == "inside":
r = s / np.interp(angle_span, [60, 90, 135, 180], [3.3, 3.5, 3.8, 4])
else:
r = s / 2
self.text.xy = c + r * np.array([np.cos(angle), np.sin(angle)])
if self.textposition == "outside":
def R90(a, r, w, h):
if a < np.arctan(h / 2 / (r + w / 2)):
return np.sqrt((r + w / 2) ** 2 + (np.tan(a) * (r + w / 2)) ** 2)
c = np.sqrt((w / 2) ** 2 + (h / 2) ** 2)
T = np.arcsin(c * np.cos(np.pi / 2 - a + np.arcsin(h / 2 / c)) / r)
xy = r * np.array([np.cos(a + T), np.sin(a + T)])
xy += np.array([w / 2, h / 2])
return np.sqrt(np.sum(xy**2))
def R(a, r, w, h):
aa = (a % (np.pi / 4)) * ((a % (np.pi / 2)) <= np.pi / 4) + (
np.pi / 4 - (a % (np.pi / 4))
) * ((a % (np.pi / 2)) >= np.pi / 4)
return R90(aa, r, *[w, h][:: int(np.sign(np.cos(2 * a)))])
bbox = self.text.get_window_extent()
X = R(angle, r, bbox.width, bbox.height)
trans = self.ax.figure.dpi_scale_trans.inverted()
offs = trans.transform(((X - s / 2), 0))[0] * 72
self.text.set_position([offs * np.cos(angle), offs * np.sin(angle)])
@Pocket-titan
Copy link
Author

Added the option to specify the size in data coordinates and fixed some bugs with the original implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment