Skip to content

Commit

Permalink
Picking and Contains for new Artists
Browse files Browse the repository at this point in the history
  • Loading branch information
ksunden committed Jun 14, 2024
1 parent 6479099 commit 47cd022
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 17 deletions.
106 changes: 94 additions & 12 deletions data_prototype/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@

import numpy as np

from matplotlib.backend_bases import PickEvent
import matplotlib.artist as martist

from .containers import DataContainer, ArrayContainer, DataUnion
from .description import Desc, desc_like
from .conversion_edge import Edge, Graph, TransformEdge
from .conversion_edge import Edge, FuncEdge, Graph, TransformEdge


class Artist:
Expand All @@ -18,6 +21,9 @@ def __init__(
kwargs_cont = ArrayContainer(**kwargs)
self._container = DataUnion(container, kwargs_cont)

self._children: list[tuple[float, Artist]] = []
self._picker = None

edges = edges or []
self._visible = True
self._graph = Graph(edges)
Expand All @@ -41,6 +47,77 @@ def get_visible(self):
def set_visible(self, visible):
self._visible = visible

def pickable(self) -> bool:
return self._picker is not None

def get_picker(self):
return self._picker

def set_picker(self, picker):
self._picker = picker

def contains(self, mouseevent, graph=None):
"""
Test whether the artist contains the mouse event.
Parameters
----------
mouseevent : `~matplotlib.backend_bases.MouseEvent`
Returns
-------
contains : bool
Whether any values are within the radius.
details : dict
An artist-specific dictionary of details of the event context,
such as which points are contained in the pick radius. See the
individual Artist subclasses for details.
"""
return False, {}

def get_children(self):
return [a[1] for a in self._children]

def pick(self, mouseevent, graph: Graph | None = None):
"""
Process a pick event.
Each child artist will fire a pick event if *mouseevent* is over
the artist and the artist has picker set.
See Also
--------
set_picker, get_picker, pickable
"""
if graph is None:
graph = self._graph
else:
graph = graph + self._graph
# Pick self
if self.pickable():
picker = self.get_picker()
if callable(picker):
inside, prop = picker(self, mouseevent)
else:
inside, prop = self.contains(mouseevent, graph)
if inside:
PickEvent(
"pick_event", mouseevent.canvas, mouseevent, self, **prop
)._process()

# Pick children
for a in self.get_children():
# make sure the event happened in the same Axes
ax = getattr(a, "axes", None)
if mouseevent.inaxes is None or ax is None or mouseevent.inaxes == ax:
# we need to check if mouseevent.inaxes is None
# because some objects associated with an Axes (e.g., a
# tick label) can be outside the bounding box of the
# Axes and inaxes will be None
# also check that ax is None so that it traverse objects
# which do not have an axes property but children might
a.pick(mouseevent, graph)


class CompatibilityArtist:
"""A compatibility shim to ducktype as a classic Matplotlib Artist.
Expand All @@ -59,7 +136,7 @@ class CompatibilityArtist:
useful for avoiding accidental dependency.
"""

def __init__(self, artist: Artist):
def __init__(self, artist: martist.Artist):
self._artist = artist

self._axes = None
Expand Down Expand Up @@ -134,7 +211,7 @@ def draw(self, renderer, graph=None):
self._artist.draw(renderer, graph + self._graph)


class CompatibilityAxes:
class CompatibilityAxes(Artist):
"""A compatibility shim to add to traditional matplotlib axes.
At this time features are implemented on an "as needed" basis, and many
Expand All @@ -152,12 +229,11 @@ class CompatibilityAxes:
"""

def __init__(self, axes):
super().__init__(ArrayContainer())
self._axes = axes
self.figure = None
self._clippath = None
self._visible = True
self.zorder = 2
self._children: list[tuple[float, Artist]] = []

@property
def axes(self):
Expand Down Expand Up @@ -187,6 +263,18 @@ def axes(self, ax):
desc_like(xy, coordinates="display"),
transform=self._axes.transAxes,
),
FuncEdge.from_func(
"xunits",
lambda: self._axes.xunits,
{},
{"xunits": Desc((), "units")},
),
FuncEdge.from_func(
"yunits",
lambda: self._axes.yunits,
{},
{"yunits": Desc((), "units")},
),
],
aliases=(("parent", "axes"),),
)
Expand All @@ -210,7 +298,7 @@ def get_animated(self):
return False

def draw(self, renderer, graph=None):
if not self.visible:
if not self.get_visible():
return
if graph is None:
graph = Graph([])
Expand All @@ -228,9 +316,3 @@ def set_xlim(self, min_=None, max_=None):

def set_ylim(self, min_=None, max_=None):
self.axes.set_ylim(min_, max_)

def get_visible(self):
return self._visible

def set_visible(self, visible):
self._visible = visible
10 changes: 9 additions & 1 deletion data_prototype/conversion_edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ def __ge__(self, other):
def __gt__(self, other):
return self.weight > other.weight

@property
def edges(self):
if self.prev_node is None:
return [self.edge]
return self.prev_node.edges + [self.edge]

q: PriorityQueue[Node] = PriorityQueue()
q.put(Node(0, input))

Expand All @@ -308,6 +314,8 @@ def __gt__(self, other):
best = n
continue
for e in sub_edges:
if e in n.edges:
continue
if Desc.compatible(n.desc, e.input, aliases=self._aliases):
d = n.desc | e.output
w = n.weight + e.weight
Expand Down Expand Up @@ -397,7 +405,7 @@ def node_format(x):
)

try:
pos = nx.planar_layout(G)
pos = nx.shell_layout(G)
except Exception:
pos = nx.circular_layout(G)
plt.figure()
Expand Down
34 changes: 30 additions & 4 deletions data_prototype/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ def _interpolate_nearest(image, x, y):
l, r = x
width = int(((round(r) + 0.5) - (round(l) - 0.5)) * magnification)

xpix = np.digitize(np.arange(width), np.linspace(0, r - l, image.shape[1] + 1))
xpix = np.digitize(np.arange(width), np.linspace(0, r - l, image.shape[1]))

b, t = y
height = int(((round(t) + 0.5) - (round(b) - 0.5)) * magnification)
ypix = np.digitize(np.arange(height), np.linspace(0, t - b, image.shape[0] + 1))
ypix = np.digitize(np.arange(height), np.linspace(0, t - b, image.shape[0]))

out = np.empty((height, width, 4))

Expand Down Expand Up @@ -53,7 +53,7 @@ def __init__(self, container, edges=None, norm=None, cmap=None, **kwargs):
{"image": Desc(("O", "P", 4), coordinates="rgba_resampled")},
)

self._edges += [
edges = [
CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"),
CoordinateEdge.from_coords(
"image_coords", {"image": Desc(("M", "N"), "auto")}, "data"
Expand All @@ -79,7 +79,7 @@ def __init__(self, container, edges=None, norm=None, cmap=None, **kwargs):
self._interpolation_edge,
]

self._graph = Graph(self._edges, (("data", "data_resampled"),))
self._graph = self._graph + Graph(edges, (("data", "data_resampled"),))

def draw(self, renderer, graph: Graph) -> None:
if not self.get_visible():
Expand Down Expand Up @@ -111,3 +111,29 @@ def draw(self, renderer, graph: Graph) -> None:
mtransforms.Bbox.from_extents(clipx[0], clipy[0], clipx[1], clipy[1])
)
renderer.draw_image(gc, x[0], y[0], image) # TODO vector backend transforms

def contains(self, mouseevent, graph=None):
if graph is None:
return False, {}
g = graph + self._graph
conv = g.evaluator(
self._container.describe(),
{
"x": Desc(("X",), "display"),
"y": Desc(("Y",), "display"),
},
).inverse
query, _ = self._container.query(g)
xmin, xmax = query["x"]
ymin, ymax = query["y"]
x, y = conv.evaluate({"x": mouseevent.x, "y": mouseevent.y}).values()

# This checks xmin <= x <= xmax *or* xmax <= x <= xmin.
inside = (
x is not None
and (x - xmin) * (x - xmax) <= 0
and y is not None
and (y - ymin) * (y - ymax) <= 0
)

return inside, {}
64 changes: 64 additions & 0 deletions data_prototype/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from .description import Desc
from .conversion_edge import Graph, CoordinateEdge, DefaultEdge

segment_hits = mlines.segment_hits


class Line(Artist):
def __init__(self, container, edges=None, **kwargs):
Expand Down Expand Up @@ -57,6 +59,68 @@ def __init__(self, container, edges=None, **kwargs):
# - non-str markers
# Each individually pretty easy, but relatively rare features, focusing on common cases

def contains(self, mouseevent, graph=None):
"""
Test whether *mouseevent* occurred on the line.
An event is deemed to have occurred "on" the line if it is less
than ``self.pickradius`` (default: 5 points) away from it. Use
`~.Line2D.get_pickradius` or `~.Line2D.set_pickradius` to get or set
the pick radius.
Parameters
----------
mouseevent : `~matplotlib.backend_bases.MouseEvent`
Returns
-------
contains : bool
Whether any values are within the radius.
details : dict
A dictionary ``{'ind': pointlist}``, where *pointlist* is a
list of points of the line that are within the pickradius around
the event position.
TODO: sort returned indices by distance
"""
if graph is None:
return False, {}

g = graph + self._graph
desc = Desc(("N",), "display")
scalar = Desc((), "display") # ... this needs thinking...
# Convert points to pixels
require = {
"x": desc,
"y": desc,
"linestyle": scalar,
}
conv = g.evaluator(self._container.describe(), require)
query, _ = self._container.query(g)
xt, yt, linestyle = conv.evaluate(query).values()

# Convert pick radius from points to pixels
pixels = 5 # self._pickradius # TODO

# The math involved in checking for containment (here and inside of
# segment_hits) assumes that it is OK to overflow, so temporarily set
# the error flags accordingly.
with np.errstate(all="ignore"):
# Check for collision
if linestyle in ["None", None]:
# If no line, return the nearby point(s)
(ind,) = np.nonzero(
(xt - mouseevent.x) ** 2 + (yt - mouseevent.y) ** 2 <= pixels**2
)
else:
# If line, return the nearby segment(s)
ind = segment_hits(mouseevent.x, mouseevent.y, xt, yt, pixels)
# if self._drawstyle.startswith("steps"):
# ind //= 2

# Return the point(s) within radius
return len(ind) > 0, dict(ind=ind)

def draw(self, renderer, graph: Graph) -> None:
if not self.get_visible():
return
Expand Down

0 comments on commit 47cd022

Please sign in to comment.