Skip to content

Picking and Contains for new artists #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.xaxis.units,
{},
{"xunits": Desc((), "units")},
),
FuncEdge.from_func(
"yunits",
lambda: self._axes.yaxis.units,
{},
{"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
4 changes: 2 additions & 2 deletions data_prototype/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ def __init__(self, coordinates: dict[str, str] | None = None, /, **data):
self._desc = {
k: (
Desc(v.shape, coordinates.get(k, "auto"))
if isinstance(v, np.ndarray)
else Desc(())
if hasattr(v, "shape")
else Desc((), coordinates.get(k, "auto"))
)
for k, v in data.items()
}
Expand Down
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
1 change: 0 additions & 1 deletion data_prototype/tests/test_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ def _verify_describe(container):
assert set(data) == set(desc)
for k, v in data.items():
assert v.shape == desc[k].shape
assert v.dtype == desc[k].dtype


def test_array_describe(ac):
Expand Down
1 change: 1 addition & 0 deletions examples/first.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@
ax.add_artist(lw2, 2)
ax.set_xlim(0, np.pi * 4)
ax.set_ylim(-1.1, 1.1)

plt.show()
Loading
Loading