From 5423dc82455459b3e13cf5cc59a837d3cf1776a3 Mon Sep 17 00:00:00 2001 From: David Bau Date: Thu, 21 Jul 2022 12:56:06 -0400 Subject: [PATCH] Add ability to decode x,y location of PlotWidget clicks. --- baukit/__init__.py | 2 +- baukit/labwidget.py | 28 ++++++++++++-- baukit/paintwidget.py | 4 +- baukit/plotwidget.py | 51 +++++++++++++++++++++++--- notebooks/using_show_and_widgets.ipynb | 33 +++++++++++++++-- 5 files changed, 103 insertions(+), 15 deletions(-) diff --git a/baukit/__init__.py b/baukit/__init__.py index 3628263..6526863 100644 --- a/baukit/__init__.py +++ b/baukit/__init__.py @@ -1,6 +1,6 @@ from .labwidget import Model, Widget, Trigger, Property, Event from .labwidget import Button, Label, Textbox, Numberbox, Range, ColorPicker -from .labwidget import Choice, Menu, Datalist, Div, ClickDiv, Image +from .labwidget import Choice, Menu, Datalist, Div, ClickDiv, Img from .labwidget import Textarea from .paintwidget import PaintWidget from .plotwidget import PlotWidget diff --git a/baukit/labwidget.py b/baukit/labwidget.py index b71d1de..cbde03c 100644 --- a/baukit/labwidget.py +++ b/baukit/labwidget.py @@ -481,6 +481,9 @@ class Event(object): self.name = name self.target = target + def __repr__(self): + return f'Event({self.value}, {self.name})' + entered_handler_stack = [] @@ -986,7 +989,7 @@ class ClickDiv(Div): ''') -class Image(Widget): +class Img(Widget): """ Just a IMG element. Use the src property to change its contents by url, or use the clear() and render(imgdata) methods to convert PIL or @@ -995,7 +998,11 @@ class Image(Widget): def __init__(self, src='', style=None, **kwargs): super().__init__(style=defaulted(style, margin=0), **kwargs) - self.src = Property(src) + if (hasattr(src, 'save') or hasattr(src, 'savefig')): + self.src = Property('') + self.render(src) + else: + self.src = Property(src) self.click = Trigger() def clear(self): @@ -1018,10 +1025,21 @@ class Image(Widget): buf.close() def widget_js(self): + # The click event has four properties that indicate the pixel + # location within the image that was clicked: imgx, imgy measure + # from the top-left. imgyb measures from the bottom, and imgxr + # measures from the right. return minify(''' model.on('src', (ev) => { element.src = ev.value; }); element.addEventListener('click', (ev) => { - model.trigger('click'); + var b=element.getBoundingClientRect(); + console.log(ev.pageX, ev.pageY) + model.trigger('click', { + x: (ev.pageX-b.left)*element.naturalWidth/element.clientWidth, + y: (ev.pageY-b.top)*element.naturalHeight/element.clientHeight, + width: element.naturalWidth, + height: element.naturalHeight + }); }); ''') @@ -1107,9 +1125,11 @@ if WIDGET_ENV is None: if WIDGET_ENV is None: try: from ipykernel.comm import Comm as jupyter_comm - COMM_MANAGER = get_ipython().kernel.comm_manager + from IPython import get_ipython as ipython_get_ipython + COMM_MANAGER = ipython_get_ipython().kernel.comm_manager WIDGET_ENV = 'jupyter' except Exception as e: + print(e) pass def no_env_warning(): diff --git a/baukit/paintwidget.py b/baukit/paintwidget.py index 1562970..98930f2 100644 --- a/baukit/paintwidget.py +++ b/baukit/paintwidget.py @@ -1,8 +1,8 @@ -from .labwidget import Widget, Property, Image, minify +from .labwidget import Widget, Property, Img, minify from . import show -class PaintWidget(Image): +class PaintWidget(Img): def __init__(self, width=256, height=256, src='', mask='', brushsize=10.0, oneshot=False, disabled=False, diff --git a/baukit/plotwidget.py b/baukit/plotwidget.py index b6a4b20..9c29983 100644 --- a/baukit/plotwidget.py +++ b/baukit/plotwidget.py @@ -1,7 +1,7 @@ -from .labwidget import Image, Property +from .labwidget import Img, Property import inspect -class PlotWidget(Image): +class PlotWidget(Img): """ A widget to create interactive matplotlib plots by defining a simple function. Example of usage: @@ -27,7 +27,7 @@ class PlotWidget(Image): import matplotlib, matplotlib.pyplot super().__init__() init_args = dict(kwargs) - render_args = dict(format='svg') + self.render_args = dict() if rc is None: rc = {} @@ -47,7 +47,7 @@ class PlotWidget(Image): for name in ['format', 'metadata', 'bbox_inches', 'pad_inches', 'facecolor', 'edgecolor', 'backend']: if name in init_args: - render_args[name] = init_args.pop(name) + self.render_args[name] = init_args.pop(name) for default_arg, default_value in [('figsize', (5, 3.5))]: if default_arg not in init_args: @@ -68,6 +68,47 @@ class PlotWidget(Image): for name in all_names: args.append(getattr(self, name)) redraw_rule(*args) - self.render(self.fig, **render_args) + self.render(self.fig, **self.render_args) self.on(' '.join(all_names), invoke_redraw) invoke_redraw() + + def event_location(self, event): + ''' + Transform a click event from pixel coordinates to plot data coordinates. + ''' + # Image natural size and image-relative pixel location. + w = event.value['width'] + h = event.value['height'] + px = event.value['x'] + py = event.value['y'] + # To convert from image to display coords, we need the bbox. + # https://stackoverflow.com/questions/28692981 + if self.render_args.get('bbox_inches', None) == 'tight': + bbox = self.fig.get_tightbbox(self.fig.canvas.get_renderer()).padded(0) + bbox.set_points(bbox.get_points() * self.fig.dpi) + else: + bbox = self.fig.bbox + # Matplotlib display-coordinate location + dx = (bbox.x1 - bbox.x0) * px / w + bbox.x0 + dy = (bbox.y1 - bbox.y0) * (h - py) / h + bbox.y0 + # Identify the first axis that contains the point + for inside in self.fig.axes: + if inside.get_window_extent().contains(dx, dy): + break + else: + inside = None + ax = self.fig.axes[0] if (len(self.fig.axes) and inside is None) else inside + # Axis-data-relative coordinate location. + # https://stackoverflow.com/questions/59794014 + if ax is not None: + x, y = ax.transData.inverted().transform((dx, dy)) + else: + x, y = None, None + + class PlotLocation(): + def __init__(self, x, y, axis, inside): + self.x = x + self.y = y + self.axis = axis + self.inside = inside + return PlotLocation(x, y, ax, inside is not None) diff --git a/notebooks/using_show_and_widgets.ipynb b/notebooks/using_show_and_widgets.ipynb index da8179d..24c8db2 100644 --- a/notebooks/using_show_and_widgets.ipynb +++ b/notebooks/using_show_and_widgets.ipynb @@ -476,20 +476,47 @@ "cell_type": "code", "execution_count": null, "id": "8ed28ab5", - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ - "\n", "freq_ra = Range(min=0.1, max=5.0, step=0.01, value=1.0)\n", "freq_nb = Numberbox(freq_ra.prop('value'))\n", "amp_ra = Range(min=0.1, max=5.0, step=0.01, value=1.0)\n", "amp_nb = Numberbox(amp_ra.prop('value'))\n", - "pw = PlotWidget(myplot, frequency=freq_ra.prop('value'), amplitude=amp_ra.prop('value'))\n", + "pw = PlotWidget(myplot, frequency=freq_ra.prop('value'), amplitude=amp_ra.prop('value'), format='svg')\n", "show(show.TIGHT, [[pw],\n", " ['frequency', show.style(flex=10), freq_ra, freq_nb],\n", " ['amplitude', show.style(flex=10), amp_ra, amp_nb]])" ] }, + { + "cell_type": "markdown", + "id": "d58975ba", + "metadata": {}, + "source": [ + "## Handling PlotWidget clicks\n", + "\n", + "When an image or PlotWidget is clicked, you can get the coordinates by listening to the click event." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba3cbe25", + "metadata": {}, + "outputs": [], + "source": [ + "from baukit import Textbox\n", + "coord_b = Textbox()\n", + "def handle_click(e):\n", + " loc = pw.event_location(e)\n", + " coord_b.value = f'x,y=({loc.x:.2f}, {loc.y:.2f}) inside={loc.inside}'\n", + "pw.on('click', handle_click)\n", + "show(show.TIGHT, [['Click the previous plot to see coordinates here:', coord_b]])" + ] + }, { "cell_type": "markdown", "id": "e60ab5fa",