{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\n# Scale invariant angle label\n\nThis example shows how to create a scale invariant angle annotation. It is\noften useful to mark angles between lines or inside shapes with a circular arc.\nWhile Matplotlib provides an `~.patches.Arc`, an inherent problem when directly\nusing it for such purposes is that an arc being circular in data space is not\nnecessarily circular in display space. Also, the arc's radius is often best\ndefined in a coordinate system which is independent of the actual data\ncoordinates - at least if you want to be able to freely zoom into your plot\nwithout the annotation growing to infinity.\n\nThis calls for a solution where the arc's center is defined in data space, but\nits radius in a physical unit like points or pixels, or as a ratio of the Axes\ndimension. The following ``AngleAnnotation`` class provides such solution.\n\nThe example below serves two purposes:\n\n* It provides a ready-to-use solution for the problem of easily drawing angles\n in graphs.\n* It shows how to subclass a Matplotlib artist to enhance its functionality, as\n well as giving a hands-on example on how to use Matplotlib's `transform\n system `.\n\nIf mainly interested in the former, you may copy the below class and jump to\nthe `angle-annotation-usage` section.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## AngleAnnotation class\nThe essential idea here is to subclass `~.patches.Arc` and set its transform\nto the `~.transforms.IdentityTransform`, making the parameters of the arc\ndefined in pixel space.\nWe then override the ``Arc``'s attributes ``_center``, ``theta1``,\n``theta2``, ``width`` and ``height`` and make them properties, coupling to\ninternal methods that calculate the respective parameters each time the\nattribute is accessed and thereby ensuring that the arc in pixel space stays\nsynchronized with the input points and size.\nFor example, each time the arc's drawing method would query its ``_center``\nattribute, instead of receiving the same number all over again, it will\ninstead receive the result of the ``get_center_in_pixels`` method we defined\nin the subclass. This method transforms the center in data coordinates to\npixels via the Axes transform ``ax.transData``. The size and the angles are\ncalculated in a similar fashion, such that the arc changes its shape\nautomatically when e.g. zooming or panning interactively.\n\nThe functionality of this class allows to annotate the arc with a text. This\ntext is a `~.text.Annotation` stored in an attribute ``text``. Since the\narc's position and radius are defined only at draw time, we need to update\nthe text's position accordingly. This is done by reimplementing the ``Arc``'s\n``draw()`` method to let it call an updating method for the text.\n\nThe arc and the text will be added to the provided Axes at instantiation: it\nis hence not strictly necessary to keep a reference to it.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\nimport numpy as np\n\nfrom matplotlib.patches import Arc\nfrom matplotlib.transforms import Bbox, IdentityTransform, TransformedBbox\n\n\nclass AngleAnnotation(Arc):\n \"\"\"\n Draws an arc between two vectors which appears circular in display space.\n \"\"\"\n def __init__(self, xy, p1, p2, size=75, unit=\"points\", ax=None,\n text=\"\", textposition=\"inside\", text_kw=None, **kwargs):\n \"\"\"\n Parameters\n ----------\n xy, p1, p2 : tuple or array of two floats\n Center position and two points. Angle annotation is drawn between\n the two vectors connecting *p1* and *p2* with *xy*, respectively.\n Units are data coordinates.\n\n size : float\n Diameter of the angle annotation in units specified by *unit*.\n\n unit : str\n One of the following strings to specify the unit of *size*:\n\n * \"pixels\": pixels\n * \"points\": points, use points instead of pixels to not have a\n dependence on the DPI\n * \"axes width\", \"axes height\": relative units of Axes width, height\n * \"axes min\", \"axes max\": minimum or maximum of relative Axes\n width, height\n\n ax : `matplotlib.axes.Axes`\n The Axes to add the angle annotation to.\n\n text : str\n The text to mark the angle with.\n\n textposition : {\"inside\", \"outside\", \"edge\"}\n Whether to show the text in- or outside the arc. \"edge\" can be used\n for custom positions anchored at the arc's edge.\n\n text_kw : dict\n Dictionary of arguments passed to the Annotation.\n\n **kwargs\n Further parameters are passed to `matplotlib.patches.Arc`. Use this\n to specify, color, linewidth etc. of the arc.\n\n \"\"\"\n self.ax = ax or plt.gca()\n self._xydata = xy # in data coordinates\n self.vec1 = p1\n self.vec2 = p2\n self.size = size\n self.unit = unit\n self.textposition = textposition\n\n super().__init__(self._xydata, size, size, angle=0.0,\n theta1=self.theta1, theta2=self.theta2, **kwargs)\n\n self.set_transform(IdentityTransform())\n self.ax.add_patch(self)\n\n self.kw = dict(ha=\"center\", va=\"center\",\n xycoords=IdentityTransform(),\n xytext=(0, 0), textcoords=\"offset points\",\n annotation_clip=True)\n self.kw.update(text_kw or {})\n self.text = ax.annotate(text, xy=self._center, **self.kw)\n\n def get_size(self):\n factor = 1.\n if self.unit == \"points\":\n factor = self.ax.figure.dpi / 72.\n elif self.unit[:4] == \"axes\":\n b = TransformedBbox(Bbox.unit(), self.ax.transAxes)\n dic = {\"max\": max(b.width, b.height),\n \"min\": min(b.width, b.height),\n \"width\": b.width, \"height\": b.height}\n factor = dic[self.unit[5:]]\n return self.size * factor\n\n def set_size(self, size):\n self.size = size\n\n def get_center_in_pixels(self):\n \"\"\"return center in pixels\"\"\"\n return self.ax.transData.transform(self._xydata)\n\n def set_center(self, xy):\n \"\"\"set center in data coordinates\"\"\"\n self._xydata = xy\n\n def get_theta(self, vec):\n vec_in_pixels = self.ax.transData.transform(vec) - self._center\n return np.rad2deg(np.arctan2(vec_in_pixels[1], vec_in_pixels[0]))\n\n def get_theta1(self):\n return self.get_theta(self.vec1)\n\n def get_theta2(self):\n return self.get_theta(self.vec2)\n\n def set_theta(self, angle):\n pass\n\n # Redefine attributes of the Arc to always give values in pixel space\n _center = property(get_center_in_pixels, set_center)\n theta1 = property(get_theta1, set_theta)\n theta2 = property(get_theta2, set_theta)\n width = property(get_size, set_size)\n height = property(get_size, set_size)\n\n # The following two methods are needed to update the text position.\n def draw(self, renderer):\n self.update_text()\n super().draw(renderer)\n\n def update_text(self):\n c = self._center\n s = self.get_size()\n angle_span = (self.theta2 - self.theta1) % 360\n angle = np.deg2rad(self.theta1 + angle_span / 2)\n r = s / 2\n if self.textposition == \"inside\":\n r = s / np.interp(angle_span, [60, 90, 135, 180],\n [3.3, 3.5, 3.8, 4])\n self.text.xy = c + r * np.array([np.cos(angle), np.sin(angle)])\n if self.textposition == \"outside\":\n def R90(a, r, w, h):\n if a < np.arctan(h/2/(r+w/2)):\n return np.sqrt((r+w/2)**2 + (np.tan(a)*(r+w/2))**2)\n else:\n c = np.sqrt((w/2)**2+(h/2)**2)\n T = np.arcsin(c * np.cos(np.pi/2 - a + np.arcsin(h/2/c))/r)\n xy = r * np.array([np.cos(a + T), np.sin(a + T)])\n xy += np.array([w/2, h/2])\n return np.sqrt(np.sum(xy**2))\n\n def R(a, r, w, h):\n aa = (a % (np.pi/4))*((a % (np.pi/2)) <= np.pi/4) + \\\n (np.pi/4 - (a % (np.pi/4)))*((a % (np.pi/2)) >= np.pi/4)\n return R90(aa, r, *[w, h][::int(np.sign(np.cos(2*a)))])\n\n bbox = self.text.get_window_extent()\n X = R(angle, r, bbox.width, bbox.height)\n trans = self.ax.figure.dpi_scale_trans.inverted()\n offs = trans.transform(((X-s/2), 0))[0] * 72\n self.text.set_position([offs*np.cos(angle), offs*np.sin(angle)])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n## Usage\n\nRequired arguments to ``AngleAnnotation`` are the center of the arc, *xy*,\nand two points, such that the arc spans between the two vectors connecting\n*p1* and *p2* with *xy*, respectively. Those are given in data coordinates.\nFurther arguments are the *size* of the arc and its *unit*. Additionally, a\n*text* can be specified, that will be drawn either in- or outside of the arc,\naccording to the value of *textposition*. Usage of those arguments is shown\nbelow.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "fig, ax = plt.subplots()\nfig.canvas.draw() # Need to draw the figure to define renderer\nax.set_title(\"AngleLabel example\")\n\n# Plot two crossing lines and label each angle between them with the above\n# ``AngleAnnotation`` tool.\ncenter = (4.5, 650)\np1 = [(2.5, 710), (6.0, 605)]\np2 = [(3.0, 275), (5.5, 900)]\nline1, = ax.plot(*zip(*p1))\nline2, = ax.plot(*zip(*p2))\npoint, = ax.plot(*center, marker=\"o\")\n\nam1 = AngleAnnotation(center, p1[1], p2[1], ax=ax, size=75, text=r\"$\\alpha$\")\nam2 = AngleAnnotation(center, p2[1], p1[0], ax=ax, size=35, text=r\"$\\beta$\")\nam3 = AngleAnnotation(center, p1[0], p2[0], ax=ax, size=75, text=r\"$\\gamma$\")\nam4 = AngleAnnotation(center, p2[0], p1[1], ax=ax, size=35, text=r\"$\\theta$\")\n\n\n# Showcase some styling options for the angle arc, as well as the text.\np = [(6.0, 400), (5.3, 410), (5.6, 300)]\nax.plot(*zip(*p))\nam5 = AngleAnnotation(p[1], p[0], p[2], ax=ax, size=40, text=r\"$\\Phi$\",\n linestyle=\"--\", color=\"gray\", textposition=\"outside\",\n text_kw=dict(fontsize=16, color=\"gray\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## ``AngleLabel`` options\n\nThe *textposition* and *unit* keyword arguments may be used to modify the\nlocation of the text label, as shown below:\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Helper function to draw angle easily.\ndef plot_angle(ax, pos, angle, length=0.95, acol=\"C0\", **kwargs):\n vec2 = np.array([np.cos(np.deg2rad(angle)), np.sin(np.deg2rad(angle))])\n xy = np.c_[[length, 0], [0, 0], vec2*length].T + np.array(pos)\n ax.plot(*xy.T, color=acol)\n return AngleAnnotation(pos, xy[0], xy[2], ax=ax, **kwargs)\n\n\nfig, (ax1, ax2) = plt.subplots(nrows=2, sharex=True)\nfig.suptitle(\"AngleLabel keyword arguments\")\nfig.canvas.draw() # Need to draw the figure to define renderer\n\n# Showcase different text positions.\nax1.margins(y=0.4)\nax1.set_title(\"textposition\")\nkw = dict(size=75, unit=\"points\", text=r\"$60\u00b0$\")\n\nam6 = plot_angle(ax1, (2.0, 0), 60, textposition=\"inside\", **kw)\nam7 = plot_angle(ax1, (3.5, 0), 60, textposition=\"outside\", **kw)\nam8 = plot_angle(ax1, (5.0, 0), 60, textposition=\"edge\",\n text_kw=dict(bbox=dict(boxstyle=\"round\", fc=\"w\")), **kw)\nam9 = plot_angle(ax1, (6.5, 0), 60, textposition=\"edge\",\n text_kw=dict(xytext=(30, 20), arrowprops=dict(arrowstyle=\"->\",\n connectionstyle=\"arc3,rad=-0.2\")), **kw)\n\nfor x, text in zip([2.0, 3.5, 5.0, 6.5], ['\"inside\"', '\"outside\"', '\"edge\"',\n '\"edge\", custom arrow']):\n ax1.annotate(text, xy=(x, 0), xycoords=ax1.get_xaxis_transform(),\n bbox=dict(boxstyle=\"round\", fc=\"w\"), ha=\"left\", fontsize=8,\n annotation_clip=True)\n\n# Showcase different size units. The effect of this can best be observed\n# by interactively changing the figure size\nax2.margins(y=0.4)\nax2.set_title(\"unit\")\nkw = dict(text=r\"$60\u00b0$\", textposition=\"outside\")\n\nam10 = plot_angle(ax2, (2.0, 0), 60, size=50, unit=\"pixels\", **kw)\nam11 = plot_angle(ax2, (3.5, 0), 60, size=50, unit=\"points\", **kw)\nam12 = plot_angle(ax2, (5.0, 0), 60, size=0.25, unit=\"axes min\", **kw)\nam13 = plot_angle(ax2, (6.5, 0), 60, size=0.25, unit=\"axes max\", **kw)\n\nfor x, text in zip([2.0, 3.5, 5.0, 6.5], ['\"pixels\"', '\"points\"',\n '\"axes min\"', '\"axes max\"']):\n ax2.annotate(text, xy=(x, 0), xycoords=ax2.get_xaxis_transform(),\n bbox=dict(boxstyle=\"round\", fc=\"w\"), ha=\"left\", fontsize=8,\n annotation_clip=True)\n\nplt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ ".. admonition:: References\n\n The use of the following functions, methods, classes and modules is shown\n in this example:\n\n - `matplotlib.patches.Arc`\n - `matplotlib.axes.Axes.annotate` / `matplotlib.pyplot.annotate`\n - `matplotlib.text.Annotation`\n - `matplotlib.transforms.IdentityTransform`\n - `matplotlib.transforms.TransformedBbox`\n - `matplotlib.transforms.Bbox`\n\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.2" } }, "nbformat": 4, "nbformat_minor": 0 }