diff --git a/docs/examples/text-annotations.ipynb b/docs/examples/text-annotations.ipynb new file mode 100644 index 0000000..be18341 --- /dev/null +++ b/docs/examples/text-annotations.ipynb @@ -0,0 +1,172 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ddddb250-faff-496c-8f0e-6935196ec14a", + "metadata": {}, + "source": [ + "# Text and Annotations\n", + "\n", + "\n", + "```{note}\n", + "Support for modifying text is not complete as none of the function implemented support updating `fontdict` or other text properties like size and color. However, the core functionality is there to place text, change it's position, or change what it reads. see https://github.com/ianhi/mpl-interactions/issues/247 for updates.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec2f6996-5f39-4009-a94d-e3a3fda108d9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%matplotlib ipympl\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from mpl_interactions import ipyplot as iplt" + ] + }, + { + "cell_type": "markdown", + "id": "692989d9-07f1-4969-81d8-fc330f0aa5d4", + "metadata": {}, + "source": [ + "## Working with text strings.\n", + "\n", + "There are two ways to dynamically update text strings in mpl-interactions.\n", + "1. Use a function to return a string\n", + "2. Use a named string formatting\n", + "\n", + "\n", + "You can also combine these and have your function return a string that then gets formatted.\n", + "\n", + "\n", + "In the example below the `xlabel` is generated using a function and the `title` is generated using the formatting approach." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d97390af-872e-42f5-a939-03b734b1cf4f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots()\n", + "\n", + "x = np.linspace(0, np.pi, 100)\n", + "\n", + "\n", + "def y(x, volts, tau):\n", + " return np.sin(x * tau) * volts\n", + "\n", + "\n", + "ctrls = iplt.plot(x, y, volts=(0.5, 10), tau=(1, 10, 100))\n", + "\n", + "\n", + "def xlabel_func(tau):\n", + " # you can do arbitrary python here to make a more\n", + " # complicated string\n", + " return f\"Time with a max tau of {np.round(tau, 3)}\"\n", + "\n", + "\n", + "with ctrls[\"tau\"]:\n", + " iplt.xlabel(xlabel_func)\n", + "with ctrls:\n", + " # directly using string formatting\n", + " # the formatting is performed in the update\n", + " iplt.title(title=\"The voltage is {volts:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "1e152d5d-6c6f-4e87-b5d6-f6755f4bed17", + "metadata": {}, + "source": [ + "## Arbitrarily placed text\n", + "\n", + "For this you can use {func}`.interactive_text`. Currently `plt.annotation` is not supported. \n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ccdd8c4-91fa-440a-9a2d-dbea694ee92d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots()\n", + "\n", + "theta = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "\n", + "def gen_string(theta):\n", + " return f\"angle = {np.round(np.rad2deg(theta))}\"\n", + "\n", + "\n", + "def fx(theta):\n", + " return np.cos(theta)\n", + "\n", + "\n", + "def fy(x, theta):\n", + " return np.sin(theta)\n", + "\n", + "\n", + "ctrls = iplt.text(fx, fy, gen_string, theta=theta)\n", + "ax.set_xlim([-1.25, 1.25])\n", + "_ = ax.set_ylim([-1.25, 1.25])" + ] + }, + { + "cell_type": "markdown", + "id": "e2aafbc0-7958-410e-a3b8-ce0a8a75ef30", + "metadata": { + "jp-MarkdownHeadingCollapsed": true, + "tags": [] + }, + "source": [ + "Since the `x` and `y` positions are scalars you can also do nifty things like directly define them by a slider shorthand in the function.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8eb7da4d-3e48-4b28-85a8-080ff85eee0d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots()\n", + "ctrls = iplt.text((0, 1, 100), (0.25, 1, 100), \"{x:.2f}, {y:.2f}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.9.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/index.md b/docs/index.md index 3db62c1..53c79e1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,6 +101,7 @@ examples/custom-callbacks.ipynb examples/animations.ipynb examples/range-sliders.ipynb examples/scalar-arguments.ipynb +examples/text-annotations.ipynb examples/tidbits.md ``` diff --git a/mpl_interactions/controller.py b/mpl_interactions/controller.py index 5db41a7..3362860 100644 --- a/mpl_interactions/controller.py +++ b/mpl_interactions/controller.py @@ -467,9 +467,12 @@ def excluder(params, except_=None): Parameters ---------- params : dict - except : str + except : str or list[str] """ - return {k: v for k, v in params.items() if k not in added_kwargs or k == except_} + if isinstance(except_, str) or except_ is None: + except_ = [except_] + + return {k: v for k, v in params.items() if k not in added_kwargs or k in except_} return excluder diff --git a/mpl_interactions/helpers.py b/mpl_interactions/helpers.py index fab60b4..2812f71 100644 --- a/mpl_interactions/helpers.py +++ b/mpl_interactions/helpers.py @@ -238,29 +238,38 @@ def f(params): def eval_xy(x_, y_, params, cache=None): """ for when y requires x as an argument and either, neither or both - of x and y may be a function. + of x and y may be a function. This will automatically do the param exclusion + for 'x' and 'y'. Returns ------- x, y as numpy arrays """ - if isinstance(x_, Callable): + if "x" in params: + # passed as a scalar with a slider + x = params["x"] + elif isinstance(x_, Callable): if cache is not None: if x_ in cache: x = cache[x_] else: x = x_(**params) + cache[x_] = x else: x = x_(**params) else: x = x_ - if isinstance(y_, Callable): + if "y" in params: + # passed a scalar with a slider + y = params["y"] + elif isinstance(y_, Callable): if cache is not None: if y_ in cache: y = cache[y_] else: y = y_(x, **params) + cache[y_] = y else: y = y_(x, **params) else: diff --git a/mpl_interactions/ipyplot.py b/mpl_interactions/ipyplot.py index 59340bc..17102a1 100644 --- a/mpl_interactions/ipyplot.py +++ b/mpl_interactions/ipyplot.py @@ -4,6 +4,7 @@ from .pyplot import interactive_imshow as imshow from .pyplot import interactive_plot as plot from .pyplot import interactive_scatter as scatter +from .pyplot import interactive_text as text from .pyplot import interactive_title as title from .pyplot import interactive_xlabel as xlabel from .pyplot import interactive_ylabel as ylabel diff --git a/mpl_interactions/pyplot.py b/mpl_interactions/pyplot.py index 03b329f..bdecbee 100644 --- a/mpl_interactions/pyplot.py +++ b/mpl_interactions/pyplot.py @@ -42,6 +42,7 @@ "interactive_title", "interactive_xlabel", "interactive_ylabel", + "interactive_text", ] @@ -1247,3 +1248,92 @@ def update(params, indices, cache): **text_kwargs, ) return controls + + +def interactive_text( + x, + y, + s, + fontdict=None, + controls=None, + ax=None, + *, + slider_formats=None, + display_controls=True, + play_buttons=False, + force_ipywidgets=False, + **kwargs, +): + """ + Create a text object that will update interactively. + kwargs for `matplotlib.text.Text` will be passed through, other kwargs will be used to create interactive controls. + + .. note:: + + fontdict properties are currently static - see https://github.com/ianhi/mpl-interactions/issues/247 + + + Parameters + ---------- + x, y : float or function + The text position. + s : str or function + The text. Can either be static text, a function returning a string or + can include {} style formatting. e.g. 'The voltage is {volts:.2f}' + fontdict : dict[str] + Passed through to the Text object. Currently not dynamically updateable. See + https://github.com/ianhi/mpl-interactions/issues/247 + controls : mpl_interactions.controller.Controls + An existing controls object if you want to tie multiple plot elements to the same set of + controls + ax : matplotlib axis, optional + The axis on which to plot. If none the current axis will be used. + play_buttons : bool or str or dict, optional + Whether to attach an ipywidgets.Play widget to any sliders that get created. + If a boolean it will apply to all kwargs, if a dictionary you choose which sliders you + want to attach play buttons too. + + - None: no sliders + - True: sliders on the lft + - False: no sliders + - 'left': sliders on the left + - 'right': sliders on the right + + force_ipywidgets : boolean + If True ipywidgets will always be used, even if not using the ipympl backend. + If False the function will try to detect if it is ok to use ipywidgets + If ipywidgets are not used the function will fall back on matplotlib widgets + + Returns + ------- + controls + """ + ipympl = notebook_backend() + fig, ax = gogogo_figure(ipympl, ax) + ipympl or force_ipywidgets + slider_formats = create_slider_format_dict(slider_formats) + + kwargs, text_kwargs = kwarg_popper(kwargs, Text_kwargs_list) + funcs, extra_ctrls, param_excluder = prep_scalars(kwargs, x=x, y=y) + x = funcs["x"] + y = funcs["y"] + controls, params = gogogo_controls( + kwargs, controls, display_controls, slider_formats, play_buttons, extra_ctrls + ) + + def update(params, indices, cache): + x_, y_ = eval_xy(x, y, param_excluder(params, ["x", "y"]), cache) + text.set_x(x_) + text.set_y(y_) + text.set_text(callable_else_value_no_cast(s, params, cache).format(**params)) + + controls._register_function(update, fig, params) + x_, y_ = eval_xy(x, y, param_excluder(params, ["x", "y"])) + text = ax.text( + x_, + y_, + callable_else_value_no_cast(s, params).format(**params), + fontdict=fontdict, + **text_kwargs, + ) + return controls