diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst new file mode 100644 index 000000000..08ab0404b --- /dev/null +++ b/docs/source/api/graphics/Graphic.rst @@ -0,0 +1,47 @@ +.. _api.Graphic: + +Graphic +******* + +======= +Graphic +======= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Graphic_api + + Graphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Graphic_api + + Graphic.axes + Graphic.block_events + Graphic.deleted + Graphic.event_handlers + Graphic.name + Graphic.offset + Graphic.right_click_menu + Graphic.rotation + Graphic.supported_events + Graphic.visible + Graphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Graphic_api + + Graphic.add_axes + Graphic.add_event_handler + Graphic.clear_event_handlers + Graphic.remove_event_handler + Graphic.rotate + Graphic.share_property + Graphic.unshare_property + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index a2addb7bf..491013dff 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -4,6 +4,7 @@ Graphics .. toctree:: :maxdepth: 1 + Graphic LineGraphic ScatterGraphic ImageGraphic diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 87c134782..3a1184e6c 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -9,6 +9,7 @@ API Reference graphics/index graphic_features/index selectors/index + tools/index ui/index widgets/index fastplotlib diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index b5cbbd2bb..d191fe8ce 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -27,6 +27,8 @@ Properties Figure.names Figure.renderer Figure.shape + Figure.show_tooltips + Figure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index a338afe96..0abfcc067 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -29,6 +29,8 @@ Properties ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape + ImguiFigure.show_tooltips + ImguiFigure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst new file mode 100644 index 000000000..d134eb1ce --- /dev/null +++ b/docs/source/api/tools/HistogramLUTTool.rst @@ -0,0 +1,53 @@ +.. _api.HistogramLUTTool: + +HistogramLUTTool +**************** + +================ +HistogramLUTTool +================ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HistogramLUTTool_api + + HistogramLUTTool + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HistogramLUTTool_api + + HistogramLUTTool.axes + HistogramLUTTool.block_events + HistogramLUTTool.cmap + HistogramLUTTool.deleted + HistogramLUTTool.event_handlers + HistogramLUTTool.image_graphic + HistogramLUTTool.name + HistogramLUTTool.offset + HistogramLUTTool.right_click_menu + HistogramLUTTool.rotation + HistogramLUTTool.supported_events + HistogramLUTTool.visible + HistogramLUTTool.vmax + HistogramLUTTool.vmin + HistogramLUTTool.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HistogramLUTTool_api + + HistogramLUTTool.add_axes + HistogramLUTTool.add_event_handler + HistogramLUTTool.clear_event_handlers + HistogramLUTTool.disconnect_image_graphic + HistogramLUTTool.remove_event_handler + HistogramLUTTool.rotate + HistogramLUTTool.set_data + HistogramLUTTool.share_property + HistogramLUTTool.unshare_property + diff --git a/docs/source/api/tools/Tooltip.rst b/docs/source/api/tools/Tooltip.rst new file mode 100644 index 000000000..71607bf20 --- /dev/null +++ b/docs/source/api/tools/Tooltip.rst @@ -0,0 +1,38 @@ +.. _api.Tooltip: + +Tooltip +******* + +======= +Tooltip +======= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Tooltip_api + + Tooltip + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Tooltip_api + + Tooltip.background_color + Tooltip.font_size + Tooltip.outline_color + Tooltip.padding + Tooltip.text_color + Tooltip.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Tooltip_api + + Tooltip.register + Tooltip.unregister + Tooltip.unregister_all + diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst new file mode 100644 index 000000000..c2666ed28 --- /dev/null +++ b/docs/source/api/tools/index.rst @@ -0,0 +1,8 @@ +Tools +***** + +.. toctree:: + :maxdepth: 1 + + HistogramLUTTool + Tooltip diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 512826b5e..0be967a36 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -9,6 +9,7 @@ from fastplotlib.layouts import Subplot from fastplotlib import graphics from fastplotlib.graphics import features, selectors +from fastplotlib import tools from fastplotlib import widgets from fastplotlib import utils from fastplotlib import ui @@ -21,6 +22,7 @@ GRAPHICS_DIR = API_DIR.joinpath("graphics") GRAPHIC_FEATURES_DIR = API_DIR.joinpath("graphic_features") SELECTORS_DIR = API_DIR.joinpath("selectors") +TOOLS_DIR = API_DIR.joinpath("tools") WIDGETS_DIR = API_DIR.joinpath("widgets") UI_DIR = API_DIR.joinpath("ui") GUIDE_DIR = current_dir.joinpath("user_guide") @@ -31,6 +33,7 @@ GRAPHICS_DIR, GRAPHIC_FEATURES_DIR, SELECTORS_DIR, + TOOLS_DIR, WIDGETS_DIR, UI_DIR, ] @@ -264,7 +267,8 @@ def main(): ) # the rest of this is a mess and can be refactored later - + ############################################################################## + # ** Graphic classes ** # graphic_classes = [getattr(graphics, g) for g in graphics.__all__] graphic_class_names = [g.__name__ for g in graphic_classes] @@ -290,7 +294,7 @@ def main(): source_path=GRAPHICS_DIR.joinpath(f"{graphic_cls.__name__}.rst"), ) ############################################################################## - + # ** GraphicFeature classes ** # feature_classes = [getattr(features, f) for f in features.__all__] feature_class_names = [f.__name__ for f in feature_classes] @@ -315,7 +319,7 @@ def main(): source_path=GRAPHIC_FEATURES_DIR.joinpath(f"{feature_cls.__name__}.rst"), ) ############################################################################## - + # ** Selector classes ** # selector_classes = [getattr(selectors, s) for s in selectors.__all__] selector_class_names = [s.__name__ for s in selector_classes] @@ -339,8 +343,35 @@ def main(): modules=["fastplotlib"], source_path=SELECTORS_DIR.joinpath(f"{selector_cls.__name__}.rst"), ) + ############################################################################## + # ** Tools classes ** # + tools_classes = [getattr(tools, t) for t in tools.__all__] + tools_class_names = [t.__name__ for t in tools_classes] + + tools_class_names_str = "\n ".join([""] + tools_class_names) + + with open(TOOLS_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Tools\n" + f"*****\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{tools_class_names_str}\n" + ) + + for tool_cls in tools_classes: + generate_page( + page_name=tool_cls.__name__, + classes=[tool_cls], + modules=["fastplotlib"], + source_path=TOOLS_DIR.joinpath(f"{tool_cls.__name__}.rst"), + ) + + ############################################################################## + # ** Widget classes ** # widget_classes = [getattr(widgets, w) for w in widgets.__all__] widget_class_names = [w.__name__ for w in widget_classes] @@ -365,7 +396,7 @@ def main(): source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst"), ) ############################################################################## - + # ** UI classes ** # ui_classes = [ui.BaseGUI, ui.Window, ui.EdgeWindow, ui.Popup] ui_class_names = [cls.__name__ for cls in ui_classes] @@ -410,6 +441,7 @@ def main(): " graphics/index\n" " graphic_features/index\n" " selectors/index\n" + " tools/index\n" " ui/index\n" " widgets/index\n" " fastplotlib\n" @@ -438,6 +470,9 @@ def write_table(name, feature_cls): f.write("============\n\n") for graphic_cls in [*graphic_classes, *selector_classes]: + if graphic_cls is graphics.Graphic: + # skip Graphic base class + continue f.write(f"{graphic_cls.__name__}\n") f.write("-" * len(graphic_cls.__name__) + "\n\n") for name, type_ in graphic_cls._features.items(): diff --git a/examples/line_collection/line_stack.py b/examples/line_collection/line_stack.py index 95b681b76..4f0c6037d 100644 --- a/examples/line_collection/line_stack.py +++ b/examples/line_collection/line_stack.py @@ -19,7 +19,10 @@ data = np.column_stack([xs, ys]) multi_data = np.stack([data] * 10) -figure = fpl.Figure(size=(700, 560)) +figure = fpl.Figure( + size=(700, 560), + show_tooltips=True +) line_stack = figure[0, 0].add_line_stack( multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] @@ -28,6 +31,26 @@ separation=1, # spacing between lines along the separation axis, default separation along "y" axis ) + +def tooltip_info(ev): + """A custom function to display the index of the graphic within the collection.""" + index = ev.pick_info["vertex_index"] # index of the line datapoint being hovered + + # get index of the hovered line within the line stack + line_index = np.where(line_stack.graphics == ev.graphic)[0].item() + info = f"line index: {line_index}\n" + + # append data value info + info += "\n".join(f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index])) + + # return str to display in tooltip + return info + +# register the line stack with the custom tooltip function +figure.tooltip_manager.register( + line_stack, custom_info=tooltip_info +) + figure.show(maintain_aspect=False) diff --git a/examples/misc/tooltips.py b/examples/misc/tooltips.py new file mode 100644 index 000000000..4fdae1482 --- /dev/null +++ b/examples/misc/tooltips.py @@ -0,0 +1,54 @@ +""" +Tooltips +======== + +Show tooltips on all graphics +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import imageio.v3 as iio +import fastplotlib as fpl + + +# get some data +scatter_data = np.random.rand(1_000, 3) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +gray = iio.imread("imageio:camera.png") +rgb = iio.imread("imageio:astronaut.png") + +# create a figure +figure = fpl.Figure( + cameras=["3d", "2d", "2d", "2d"], + controller_types=["orbit", "panzoom", "panzoom", "panzoom"], + size=(700, 560), + shape=(2, 2), + show_tooltips=True, # tooltip will display data value info for all graphics +) + +# create graphics +scatter = figure[0, 0].add_scatter(scatter_data, sizes=3, colors="r") +line = figure[0, 1].add_line(np.column_stack([xs, ys])) +image = figure[1, 0].add_image(gray) +image_rgb = figure[1, 1].add_image(rgb) + + +figure.show() + +# to hide tooltips for all graphics in an existing Figure +# figure.show_tooltips = False + +# to show tooltips for all graphics in an existing Figure +# figure.show_tooltips = True + + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py new file mode 100644 index 000000000..a62190906 --- /dev/null +++ b/examples/misc/tooltips_custom.py @@ -0,0 +1,54 @@ +""" +Tooltips Customization +====================== + +Customize the information displayed in a tooltip. This example uses the Iris dataset and sets the tooltip to display +the species and cluster label of the point that is being hovered by the mouse pointer. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + + +import fastplotlib as fpl +from sklearn.cluster import AgglomerativeClustering +from sklearn import datasets + + +figure = fpl.Figure(size=(700, 560)) + +dataset = datasets.load_iris() +data = dataset["data"] + +agg = AgglomerativeClustering(n_clusters=3) +agg.fit_predict(data) + +scatter_graphic = figure[0, 0].add_scatter( + data=data[:, :-1], # use only xy data + sizes=15, + cmap="Set1", + cmap_transform=agg.labels_ # use the labels as a transform to map colors from the colormap +) + + +def tooltip_info(ev) -> str: + # get index of the scatter point that is being hovered + index = ev.pick_info["vertex_index"] + + # get the species name + target = dataset["target"][index] + cluster = agg.labels_[index] + info = f"species: {dataset['target_names'][target]}\ncluster: {cluster}" + + # return this string to display it in the tooltip + return info + + +figure.tooltip_manager.register(scatter_graphic, custom_info=tooltip_info) + +figure.show() + + +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 03f361502..b458a8c48 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,6 +7,7 @@ __all__ = [ + "Graphic", "LineGraphic", "ScatterGraphic", "ImageGraphic", diff --git a/fastplotlib/layouts/_engine.py b/fastplotlib/layouts/_engine.py index 877a7fbab..bf73d5f0d 100644 --- a/fastplotlib/layouts/_engine.py +++ b/fastplotlib/layouts/_engine.py @@ -7,7 +7,7 @@ from ._rect import RectManager -class UnderlayCamera(pygfx.Camera): +class ScreenSpaceCamera(pygfx.Camera): """ Same as pygfx.ScreenCoordsCamera but y-axis is inverted. diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index a1bae965e..fb9e8e279 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -19,8 +19,9 @@ ) from ._utils import controller_types as valid_controller_types from ._subplot import Subplot -from ._engine import GridLayout, WindowLayout, UnderlayCamera +from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera from .. import ImageGraphic +from ..tools import Tooltip class Figure: @@ -51,6 +52,7 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, + show_tooltips: bool = False, ): """ Create a Figure containing Subplots. @@ -121,6 +123,9 @@ def __init__( names: list or array of str, optional subplot names + show_tooltips: bool, default False + show tooltips on graphics + """ if rects is not None: @@ -409,13 +414,23 @@ def __init__( canvas_rect=self.get_pygfx_render_area(), ) - self._underlay_camera = UnderlayCamera() - + # underlay render pass + self._underlay_camera = ScreenSpaceCamera() self._underlay_scene = pygfx.Scene() for subplot in self._subplots.ravel(): self._underlay_scene.add(subplot.frame._world_object) + # overlay render pass + self._overlay_camera = ScreenSpaceCamera() + self._overlay_scene = pygfx.Scene() + + # tooltip in overlay render pass + self._tooltip_manager = Tooltip() + self._overlay_scene.add(self._tooltip_manager.world_object) + + self._show_tooltips = show_tooltips + self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() @@ -483,6 +498,29 @@ def names(self) -> np.ndarray[str]: names.flags.writeable = False return names + @property + def tooltip_manager(self) -> Tooltip: + """manage tooltips""" + return self._tooltip_manager + + @property + def show_tooltips(self) -> bool: + """show/hide tooltips for all graphics""" + return self._show_tooltips + + @show_tooltips.setter + def show_tooltips(self, val: bool): + self._show_tooltips = val + + if val: + # register all graphics + for subplot in self: + for graphic in subplot.graphics: + self._tooltip_manager.register(graphic) + + elif not val: + self._tooltip_manager.unregister_all() + def _render(self, draw=True): # draw the underlay planes self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False) @@ -492,6 +530,9 @@ def _render(self, draw=True): for subplot in self: subplot._render() + # overlay render pass + self.renderer.render(self._overlay_scene, self._overlay_camera, flush=False) + self.renderer.flush() # call post-render animate functions diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index b0267dc75..c54890239 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -44,6 +44,7 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, + show_tooltips: bool = False, ): self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} @@ -60,6 +61,7 @@ def __init__( canvas_kwargs=canvas_kwargs, size=size, names=names, + show_tooltips=show_tooltips, ) self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 2934e0589..2542fc215 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -491,6 +491,10 @@ def _add_or_insert_graphic( obj_list = self._graphics self._fpl_graphics_scene.add(graphic.world_object) + # add to tooltip registry + if self.get_figure().show_tooltips: + self.get_figure().tooltip_manager.register(graphic) + else: raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") @@ -504,7 +508,6 @@ def _add_or_insert_graphic( if center: self.center_graphic(graphic) - # if we don't use the weakref above, then the object lingers if a plot hook is used! graphic._fpl_add_plot_area_hook(self) def _check_graphic_name_exists(self, name): diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index 80396c98d..df129a369 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1 +1,7 @@ from ._histogram_lut import HistogramLUTTool +from ._tooltip import Tooltip + +__all__ = [ + "HistogramLUTTool", + "Tooltip", +] diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_tooltip.py new file mode 100644 index 000000000..2fbdfcec2 --- /dev/null +++ b/fastplotlib/tools/_tooltip.py @@ -0,0 +1,297 @@ +from functools import partial + +import numpy as np +import pygfx + +from ..graphics import LineGraphic, ImageGraphic, ScatterGraphic, Graphic +from ..graphics.features import GraphicFeatureEvent + + +class MeshMasks: + """Used set the x0, x1, y0, y1 positions of the plane mesh""" + + x0 = np.array( + [ + [False, False, False], + [True, False, False], + [False, False, False], + [True, False, False], + ] + ) + + x1 = np.array( + [ + [True, False, False], + [False, False, False], + [True, False, False], + [False, False, False], + ] + ) + + y0 = np.array( + [ + [False, True, False], + [False, True, False], + [False, False, False], + [False, False, False], + ] + ) + + y1 = np.array( + [ + [False, False, False], + [False, False, False], + [False, True, False], + [False, True, False], + ] + ) + + +masks = MeshMasks + + +class Tooltip: + def __init__(self): + # text object + self._text = pygfx.Text( + text="", + font_size=12, + screen_space=False, + anchor="bottom-left", + material=pygfx.TextMaterial( + color="w", + outline_color="w", + outline_thickness=0.0, + pick_write=False, + ), + ) + + # plane for the background of the text object + geometry = pygfx.plane_geometry(1, 1) + material = pygfx.MeshBasicMaterial(color=(0.1, 0.1, 0.3, 0.95)) + self._plane = pygfx.Mesh(geometry, material) + # else text not visible + self._plane.world.z = 0.5 + + # line to outline the plane mesh + self._line = pygfx.Line( + geometry=pygfx.Geometry( + positions=np.array( + [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + dtype=np.float32, + ) + ), + material=pygfx.LineThinMaterial(thickness=1.0, color=(0.8, 0.8, 1.0, 1.0)), + ) + + self._world_object = pygfx.Group() + self._world_object.add(self._plane, self._text, self._line) + + # padded to bbox so the background box behind the text extends a bit further + # making the text easier to read + self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) + + self._registered_graphics = dict() + + @property + def world_object(self) -> pygfx.Group: + return self._world_object + + @property + def font_size(self): + """Get or set font size""" + return self._text.font_size + + @font_size.setter + def font_size(self, size: float): + self._text.font_size = size + + @property + def text_color(self): + """Get or set text color using a str or RGB(A) array""" + return self._text.material.color + + @text_color.setter + def text_color(self, color: str | tuple | list | np.ndarray): + self._text.material.color = color + + @property + def background_color(self): + """Get or set background color using a str or RGB(A) array""" + return self._plane.material.color + + @background_color.setter + def background_color(self, color: str | tuple | list | np.ndarray): + self._plane.material.color = color + + @property + def outline_color(self): + """Get or set outline color using a str or RGB(A) array""" + return self._line.material.color + + @outline_color.setter + def outline_color(self, color: str | tuple | list | np.ndarray): + self._line.material.color = color + + @property + def padding(self) -> np.ndarray: + """ + Get or set the background padding in number of pixels. + The padding defines the number of pixels around the tooltip text that the background is extended by. + """ + + return self.padding[0, :2].copy() + + @padding.setter + def padding(self, padding_xy: tuple[float, float]): + self._padding[0, :2] = padding_xy + self._padding[1, :2] = -np.asarray(padding_xy) + + def _set_position(self, pos: tuple[float, float]): + """ + Set the position of the tooltip + + Parameters + ---------- + pos: [float, float] + position in screen space + + """ + # need to flip due to inverted y + x, y = pos[0], pos[1] + + # put the tooltip slightly to the top right of the cursor positoin + x += 8 + y -= 8 + + self._text.world.position = (x, -y, 0) + + bbox = self._text.get_world_bounding_box() - self._padding + [[x0, y0, _], [x1, y1, _]] = bbox + + self._plane.geometry.positions.data[masks.x0] = x0 + self._plane.geometry.positions.data[masks.x1] = x1 + self._plane.geometry.positions.data[masks.y0] = y0 + self._plane.geometry.positions.data[masks.y1] = y1 + + self._plane.geometry.positions.update_range() + + # line points + pts = [[x0, y0], [x0, y1], [x1, y1], [x1, y0], [x0, y0]] + + self._line.geometry.positions.data[:, :2] = pts + self._line.geometry.positions.update_range() + + def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): + """Handles the tooltip appear event, determines the text to be set in the tooltip""" + if custom_tooltip is not None: + info = custom_tooltip(ev) + + elif isinstance(ev.graphic, ImageGraphic): + col, row = ev.pick_info["index"] + if ev.graphic.data.value.ndim == 2: + info = str(ev.graphic.data[row, col]) + else: + info = "\n".join( + f"{channel}: {val}" + for channel, val in zip("rgba", ev.graphic.data[row, col]) + ) + + elif isinstance(ev.graphic, (LineGraphic, ScatterGraphic)): + index = ev.pick_info["vertex_index"] + info = "\n".join( + f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) + ) + else: + raise TypeError("Unsupported graphic") + + # make the tooltip object visible + self.world_object.visible = True + + # set the text and top left position of the tooltip + self._text.set_text(info) + self._set_position((ev.x, ev.y)) + + def _clear(self, ev): + self._text.set_text("") + self.world_object.visible = False + + def register( + self, + graphic: Graphic, + appear_event: str = "pointer_move", + disappear_event: str = "pointer_leave", + custom_info: callable = None, + ): + """ + Register a Graphic to display tooltips. + + **Note:** if the passed graphic is already registered then it first unregistered + and then re-registered using the given arguments. + + Parameters + ---------- + graphic: Graphic + Graphic to register + + appear_event: str, default "pointer_move" + the pointer that triggers the tooltip to appear. Usually one of "pointer_move" | "click" | "double_click" + + disappear_event: str, default "pointer_leave" + the event that triggers the tooltip to disappear, does not have to be a pointer event. + + custom_info: callable, default None + a custom function that takes the pointer event defined as the `appear_event` and returns the text + to display in the tooltip + + """ + if graphic in list(self._registered_graphics.keys()): + # unregister first and then re-register + self.unregister(graphic) + + pfunc = partial(self._event_handler, custom_info) + graphic.add_event_handler(pfunc, appear_event) + graphic.add_event_handler(self._clear, disappear_event) + + self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) + + # automatically unregister when graphic is deleted + graphic.add_event_handler(self.unregister, "deleted") + + def unregister(self, graphic: Graphic): + """ + Unregister a Graphic to no longer display tooltips for this graphic. + + **Note:** if the passed graphic is not registered then it is just ignored without raising any exception. + + Parameters + ---------- + graphic: Graphic + Graphic to unregister + + """ + + if isinstance(graphic, GraphicFeatureEvent): + # this happens when the deleted event is triggered + graphic = graphic.graphic + + if graphic not in self._registered_graphics: + return + + # get pfunc and event names + pfunc, appear_event, disappear_event = self._registered_graphics.pop(graphic) + + # remove handlers from graphic + graphic.remove_event_handler(pfunc, appear_event) + graphic.remove_event_handler(self._clear, disappear_event) + + def unregister_all(self): + """unregister all graphics""" + for graphic in self._registered_graphics.keys(): + self.unregister(graphic)