From 59ed74c952d656a62f6048265a4b2c888de6d475 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Feb 2024 05:40:04 -0500 Subject: [PATCH 01/20] start legends, not functional yet --- fastplotlib/widgets/_legends/__init__.py | 0 fastplotlib/widgets/_legends/legend.py | 107 +++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 fastplotlib/widgets/_legends/__init__.py create mode 100644 fastplotlib/widgets/_legends/legend.py diff --git a/fastplotlib/widgets/_legends/__init__.py b/fastplotlib/widgets/_legends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/_legends/legend.py b/fastplotlib/widgets/_legends/legend.py new file mode 100644 index 000000000..b87ebd3fe --- /dev/null +++ b/fastplotlib/widgets/_legends/legend.py @@ -0,0 +1,107 @@ +from functools import partial +from typing import * + +import numpy as np +import pygfx + +from ...layouts._subplot import Subplot, Dock +from ...layouts import Plot +from ...graphics._base import Graphic +from ...graphics._features._base import FeatureEvent +from ...graphics import LineGraphic, ScatterGraphic, ImageGraphic + + +class Legend: + def __init__(self, plot_area: Union[Plot, Subplot, Dock]): + """ + + Parameters + ---------- + plot_area: Union[Plot, Subplot, Dock] + plot area to put the legend in + + """ + self._graphics: List[Graphic] = list() + + self._items: Dict[Graphic: LegendItem] = dict() + + def graphics(self) -> Tuple[Graphic, ...]: + return tuple(self._graphics) + + def add_graphic(self, graphic: Graphic): + graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + + def remove_graphic(self, graphic: Graphic): + pass + + +class LegendItem: + def __init__( + self, + label: str, + color: Any, + ): + """ + + Parameters + ---------- + label: str + + color: Any + """ + self._label = label + self._color = color + + +class LineLegendItem(LegendItem): + def __init__( + self, + line_graphic: LineGraphic, + label: str, + color: Any, + thickness: float + ): + """ + + Parameters + ---------- + label: str + + color: Any + """ + super().__init__(label, color) + + line_graphic.colors.add_event_handler(self._update_color) + line_graphic.thickness.add_event_handler(self._update_thickness) + + # construct Line WorldObject + data = np.array( + [[0, 1], + [0, 0], + [0, 0]] + ) + + if thickness < 1.1: + material = pygfx.LineThinMaterial + else: + material = pygfx.LineMaterial + + self._world_object = pygfx.Line( + geometry=pygfx.Geometry(positions=data), + material=material(thickness=thickness, color=pygfx.Color(color)) + ) + + @property + def label(self) -> str: + return self._label + + @label.setter + def label(self, text: str): + pass + + def _update_color(self, ev: FeatureEvent): + new_color = ev.pick_info["new_data"] + self._world_object.material.color = pygfx.Color(new_color) + + def _update_thickness(self, ev: FeatureEvent): + self._world_object.material.thickness = ev.pick_info["new_data"] From 6966097c4070fc6e2d22f8ea2502eb14dbe12cc3 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 15 Feb 2024 00:57:30 -0500 Subject: [PATCH 02/20] add faq (#400) --- docs/source/index.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 7e1d3865a..a6d36dd4f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,12 +39,23 @@ For installation please see the instructions on GitHub: https://github.com/kushalkolar/fastplotlib#installation +FAQ +=== + +1. Axes, axis, ticks, labels, legends + +A: They are on the `roadmap `_ and expected by summer 2024 :) + +2. Why the parrot logo? + +A: The logo is a `swift parrot `_, they are the fastest species of parrot and they are colorful like fastplotlib visualizations :D + Contributing ============ Contributions are welcome! See the contributing guide on GitHub: https://github.com/kushalkolar/fastplotlib/blob/master/CONTRIBUTING.md. -Also take a look at the `Roadmap 2023 `_ for future plans or ways in which you could contribute. +Also take a look at the `Roadmap 2025 `_ for future plans or ways in which you could contribute. Indices and tables ================== From 0967ea021fee557c3b1b9739103c00c1bbcac618 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 19 Feb 2024 05:43:45 -0500 Subject: [PATCH 03/20] fix bug when remove_graphic() is used (#405) --- fastplotlib/layouts/_plot_area.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 9522832d3..819efa205 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -373,6 +373,12 @@ def add_graphic(self, graphic: Graphic, center: bool = True): Center the camera on the newly added Graphic """ + + if graphic in self: + # graphic is already in this plot but was removed from the scene, add it back + self.scene.add(graphic.world_object) + return + self._add_or_insert_graphic(graphic=graphic, center=center, action="add") graphic.position_z = len(self) From d537a4ebd082bdc66173244f02dc5745e3798587 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 19 Feb 2024 22:35:35 -0500 Subject: [PATCH 04/20] add 'Deleted' as a graphic feature (#404) --- fastplotlib/graphics/_base.py | 11 +++++- fastplotlib/graphics/_features/__init__.py | 2 ++ fastplotlib/graphics/_features/_deleted.py | 41 ++++++++++++++++++++++ fastplotlib/graphics/image.py | 7 ++-- fastplotlib/graphics/line.py | 2 +- fastplotlib/graphics/line_collection.py | 2 +- fastplotlib/graphics/scatter.py | 2 +- 7 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 fastplotlib/graphics/_features/_deleted.py diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a0b4881fb..eea78142c 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -8,7 +8,7 @@ from pygfx import WorldObject -from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable +from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable, Deleted # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects @@ -45,6 +45,12 @@ def __init_subclass__(cls, **kwargs): class Graphic(BaseGraphic): + feature_events = {} + + def __init_subclass__(cls, **kwargs): + # all graphics give off a feature event when deleted + cls.feature_events = {*cls.feature_events, "deleted"} + def __init__( self, name: str = None, @@ -72,6 +78,8 @@ def __init__( # store hex id str of Graphic instance mem location self.loc: str = hex(id(self)) + self.deleted = Deleted(self, False) + @property def world_object(self) -> WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" @@ -168,6 +176,7 @@ def _cleanup(self): pass def __del__(self): + self.deleted = True del WORLD_OBJECTS[self.loc] diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index a6ce9c3a3..a1769b010 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -5,6 +5,7 @@ from ._thickness import ThicknessFeature from ._base import GraphicFeature, GraphicFeatureIndexable, FeatureEvent, to_gpu_supported_dtype from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature +from ._deleted import Deleted __all__ = [ "ColorFeature", @@ -23,4 +24,5 @@ "to_gpu_supported_dtype", "LinearSelectionFeature", "LinearRegionSelectionFeature", + "Deleted", ] diff --git a/fastplotlib/graphics/_features/_deleted.py b/fastplotlib/graphics/_features/_deleted.py new file mode 100644 index 000000000..2fca1c719 --- /dev/null +++ b/fastplotlib/graphics/_features/_deleted.py @@ -0,0 +1,41 @@ +from ._base import GraphicFeature, FeatureEvent + + +class Deleted(GraphicFeature): + """ + Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted + + **event pick info:** + + ==================== ======================== ========================================================================= + key type description + ==================== ======================== ========================================================================= + "collection-index" int the index of the graphic within the collection that triggered the event + "world_object" pygfx.WorldObject world object + ==================== ======================== ========================================================================= + """ + + def __init__(self, parent, value: bool): + super(Deleted, self).__init__(parent, value) + + def _set(self, value: bool): + value = self._parse_set_value(value) + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key, new_data): + # this is a non-indexable feature so key=None + + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data, + } + + event_data = FeatureEvent(type="deleted", pick_info=pick_info) + + self._call_event_handlers(event_data) + + def __repr__(self) -> str: + s = f"DeletedFeature for {self._parent}" + return s diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 10f09eefb..3d629c10f 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -196,7 +196,7 @@ def _add_plot_area_hook(self, plot_area): class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = ("data", "cmap", "present") + feature_events = {"data", "cmap", "present"} def __init__( self, @@ -345,10 +345,11 @@ def col_chunk_index(self, index: int): class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = ( + feature_events = { "data", "cmap", - ) + "present" + } def __init__( self, diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index d6f061ab0..9ac7568a7 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -12,7 +12,7 @@ class LineGraphic(Graphic, Interaction): - feature_events = ("data", "colors", "cmap", "thickness", "present") + feature_events = {"data", "colors", "cmap", "thickness", "present"} def __init__( self, diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 38597a830..a5c398130 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -15,7 +15,7 @@ class LineCollection(GraphicCollection, Interaction): child_type = LineGraphic.__name__ - feature_events = ("data", "colors", "cmap", "thickness", "present") + feature_events = {"data", "colors", "cmap", "thickness", "present"} def __init__( self, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 961324c23..63689fad9 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -9,7 +9,7 @@ class ScatterGraphic(Graphic): - feature_events = ("data", "sizes", "colors", "cmap", "present") + feature_events = {"data", "sizes", "colors", "cmap", "present"} def __init__( self, From 0dc0d959215708529c295341ad62c0fe89b0de53 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 20 Feb 2024 01:37:02 -0500 Subject: [PATCH 05/20] very basic adding line legends works --- fastplotlib/__init__.py | 4 +- fastplotlib/graphics/_base.py | 1 + fastplotlib/legends/__init__.py | 3 + fastplotlib/legends/legend.py | 203 +++++++++++++++++++++++ fastplotlib/widgets/_legends/__init__.py | 0 fastplotlib/widgets/_legends/legend.py | 107 ------------ 6 files changed, 210 insertions(+), 108 deletions(-) create mode 100644 fastplotlib/legends/__init__.py create mode 100644 fastplotlib/legends/legend.py delete mode 100644 fastplotlib/widgets/_legends/__init__.py delete mode 100644 fastplotlib/widgets/_legends/legend.py diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 301412aff..65371ee28 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -3,6 +3,7 @@ from .layouts import Plot, GridPlot from .graphics import * from .graphics.selectors import * +from .legends import * from wgpu.gui.auto import run @@ -21,5 +22,6 @@ "Plot", "GridPlot", "run", - "ImageWidget" + "ImageWidget", + "Legend", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index eea78142c..ae598ef3e 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -48,6 +48,7 @@ class Graphic(BaseGraphic): feature_events = {} def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) # all graphics give off a feature event when deleted cls.feature_events = {*cls.feature_events, "deleted"} diff --git a/fastplotlib/legends/__init__.py b/fastplotlib/legends/__init__.py new file mode 100644 index 000000000..507251f59 --- /dev/null +++ b/fastplotlib/legends/__init__.py @@ -0,0 +1,3 @@ +from .legend import Legend + +__all__ = ["Legend"] diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py new file mode 100644 index 000000000..7eb603b5d --- /dev/null +++ b/fastplotlib/legends/legend.py @@ -0,0 +1,203 @@ +from functools import partial +from typing import * + +import numpy as np +import pygfx + +from fastplotlib.graphics._base import Graphic +from fastplotlib.graphics._features._base import FeatureEvent +from fastplotlib.graphics import LineGraphic, ScatterGraphic, ImageGraphic + + +y_bottom = np.array( + [ + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + False, + False, + ] +) + +class Legend(Graphic): + def __init__(self, plot_area, *args, **kwargs): + """ + + Parameters + ---------- + plot_area: Union[Plot, Subplot, Dock] + plot area to put the legend in + + """ + self._graphics: List[Graphic] = list() + + # hex id of Graphic, i.e. graphic.loc are the keys + self._items: Dict[str: LegendItem] = dict() + + super().__init__(**kwargs) + + group = pygfx.Group() + self._set_world_object(group) + + self._mesh = pygfx.Mesh( + pygfx.box_geometry(50, 10, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10) + ) + + self.world_object.add(self._mesh) + + plot_area.add_graphic(self) + + def graphics(self) -> Tuple[Graphic, ...]: + return tuple(self._graphics) + + def add_graphic(self, graphic: Graphic, label: str = None): + if isinstance(graphic, LineGraphic): + y_pos = len(self._items) * -10 + legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) + + self._mesh.geometry.positions.data[y_bottom, 1] += y_pos + self._mesh.geometry.positions.update_range() + + self.world_object.add(legend_item.world_object) + + else: + raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") + + self._items[graphic.loc] = legend_item + graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + + def remove_graphic(self, graphic: Graphic): + self._graphics.remove(graphic) + legend_item = self._items.pop(graphic.loc) + self.world_object.remove(legend_item.world_object) + + +class LegendItem: + def __init__( + self, + label: str, + color: pygfx.Color, + ): + """ + + Parameters + ---------- + label: str + + color: pygfx.Color + """ + self._label = label + self._color = color + + +class LineLegendItem(LegendItem): + def __init__( + self, + graphic: LineGraphic, + label: str, + position: Tuple[int, int] + ): + """ + + Parameters + ---------- + graphic: LineGraphic + + label: str + + position: [x, y] + """ + + if label is not None: + pass + + elif graphic.name is not None: + pass + + else: + raise ValueError("Must specify `label` or Graphic must have a `name` to auto-use as the label") + + if np.unique(graphic.colors(), axis=0).shape[0] > 1: + raise ValueError("Use colorbars for multi-colored lines, not legends") + + color = pygfx.Color(np.unique(graphic.colors(), axis=0).ravel()) + + super().__init__(label, color) + + graphic.colors.add_event_handler(self._update_color) + graphic.thickness.add_event_handler(self._update_thickness) + + # construct Line WorldObject + data = np.array( + [[0, 0, 0], + [3, 0, 0]], + dtype=np.float32 + ) + + if graphic.thickness() < 1.1: + material = pygfx.LineThinMaterial + else: + material = pygfx.LineMaterial + + self._line_world_object = pygfx.Line( + geometry=pygfx.Geometry(positions=data), + material=material(thickness=graphic.thickness(), color=pygfx.Color(color)) + ) + + self._line_world_object.world.x = -20 + position[0] + self._line_world_object.world.y = self._line_world_object.world.y + position[1] + + self._label_world_object = pygfx.Text( + geometry=pygfx.TextGeometry( + text=str(label), + font_size=6, + screen_space=False, + anchor="middle-left", + ), + material=pygfx.TextMaterial( + color="w", + outline_color="w", + outline_thickness=0, + ) + ) + self._label_world_object.world.x = self._label_world_object.world.x - 10 + position[0] + self._label_world_object.world.y = self._label_world_object.world.y + position[1] + + self.world_object = pygfx.Group() + self.world_object.add(self._line_world_object, self._label_world_object) + self.world_object.world.z = 2 + + @property + def label(self) -> str: + return self._label + + @label.setter + def label(self, text: str): + self._label_world_object.geometry.set_text(text) + + def _update_color(self, ev: FeatureEvent): + new_color = ev.pick_info["new_data"] + self._line_world_object.material.color = pygfx.Color(new_color) + + def _update_thickness(self, ev: FeatureEvent): + self._line_world_object.material.thickness = ev.pick_info["new_data"] diff --git a/fastplotlib/widgets/_legends/__init__.py b/fastplotlib/widgets/_legends/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/fastplotlib/widgets/_legends/legend.py b/fastplotlib/widgets/_legends/legend.py deleted file mode 100644 index b87ebd3fe..000000000 --- a/fastplotlib/widgets/_legends/legend.py +++ /dev/null @@ -1,107 +0,0 @@ -from functools import partial -from typing import * - -import numpy as np -import pygfx - -from ...layouts._subplot import Subplot, Dock -from ...layouts import Plot -from ...graphics._base import Graphic -from ...graphics._features._base import FeatureEvent -from ...graphics import LineGraphic, ScatterGraphic, ImageGraphic - - -class Legend: - def __init__(self, plot_area: Union[Plot, Subplot, Dock]): - """ - - Parameters - ---------- - plot_area: Union[Plot, Subplot, Dock] - plot area to put the legend in - - """ - self._graphics: List[Graphic] = list() - - self._items: Dict[Graphic: LegendItem] = dict() - - def graphics(self) -> Tuple[Graphic, ...]: - return tuple(self._graphics) - - def add_graphic(self, graphic: Graphic): - graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) - - def remove_graphic(self, graphic: Graphic): - pass - - -class LegendItem: - def __init__( - self, - label: str, - color: Any, - ): - """ - - Parameters - ---------- - label: str - - color: Any - """ - self._label = label - self._color = color - - -class LineLegendItem(LegendItem): - def __init__( - self, - line_graphic: LineGraphic, - label: str, - color: Any, - thickness: float - ): - """ - - Parameters - ---------- - label: str - - color: Any - """ - super().__init__(label, color) - - line_graphic.colors.add_event_handler(self._update_color) - line_graphic.thickness.add_event_handler(self._update_thickness) - - # construct Line WorldObject - data = np.array( - [[0, 1], - [0, 0], - [0, 0]] - ) - - if thickness < 1.1: - material = pygfx.LineThinMaterial - else: - material = pygfx.LineMaterial - - self._world_object = pygfx.Line( - geometry=pygfx.Geometry(positions=data), - material=material(thickness=thickness, color=pygfx.Color(color)) - ) - - @property - def label(self) -> str: - return self._label - - @label.setter - def label(self, text: str): - pass - - def _update_color(self, ev: FeatureEvent): - new_color = ev.pick_info["new_data"] - self._world_object.material.color = pygfx.Color(new_color) - - def _update_thickness(self, ev: FeatureEvent): - self._world_object.material.thickness = ev.pick_info["new_data"] From c7f6284ee32ea90a4e067b9fa33bce864a3b4fe7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 20 Feb 2024 01:41:48 -0500 Subject: [PATCH 06/20] use OrderedDict for legend items --- fastplotlib/legends/legend.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 7eb603b5d..4ea9a6c7b 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -1,4 +1,5 @@ from functools import partial +from collections import OrderedDict from typing import * import numpy as np @@ -51,7 +52,7 @@ def __init__(self, plot_area, *args, **kwargs): self._graphics: List[Graphic] = list() # hex id of Graphic, i.e. graphic.loc are the keys - self._items: Dict[str: LegendItem] = dict() + self._items: OrderedDict[str: LegendItem] = OrderedDict() super().__init__(**kwargs) @@ -91,6 +92,11 @@ def remove_graphic(self, graphic: Graphic): legend_item = self._items.pop(graphic.loc) self.world_object.remove(legend_item.world_object) + # figure out logic of removing items and re-ordering + # for i, (graphic_loc, legend_item) in enumerate(self._items.items()): + # pass + + class LegendItem: def __init__( From fee30f587453f0a3c75fd14f91d77e65bc200826 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 20 Feb 2024 01:54:45 -0500 Subject: [PATCH 07/20] allow accessing legend items via Graphic, updating colors works --- fastplotlib/legends/legend.py | 130 +++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 4ea9a6c7b..266d13429 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -39,64 +39,6 @@ ] ) -class Legend(Graphic): - def __init__(self, plot_area, *args, **kwargs): - """ - - Parameters - ---------- - plot_area: Union[Plot, Subplot, Dock] - plot area to put the legend in - - """ - self._graphics: List[Graphic] = list() - - # hex id of Graphic, i.e. graphic.loc are the keys - self._items: OrderedDict[str: LegendItem] = OrderedDict() - - super().__init__(**kwargs) - - group = pygfx.Group() - self._set_world_object(group) - - self._mesh = pygfx.Mesh( - pygfx.box_geometry(50, 10, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10) - ) - - self.world_object.add(self._mesh) - - plot_area.add_graphic(self) - - def graphics(self) -> Tuple[Graphic, ...]: - return tuple(self._graphics) - - def add_graphic(self, graphic: Graphic, label: str = None): - if isinstance(graphic, LineGraphic): - y_pos = len(self._items) * -10 - legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) - - self._mesh.geometry.positions.data[y_bottom, 1] += y_pos - self._mesh.geometry.positions.update_range() - - self.world_object.add(legend_item.world_object) - - else: - raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") - - self._items[graphic.loc] = legend_item - graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) - - def remove_graphic(self, graphic: Graphic): - self._graphics.remove(graphic) - legend_item = self._items.pop(graphic.loc) - self.world_object.remove(legend_item.world_object) - - # figure out logic of removing items and re-ordering - # for i, (graphic_loc, legend_item) in enumerate(self._items.items()): - # pass - - class LegendItem: def __init__( @@ -203,7 +145,77 @@ def label(self, text: str): def _update_color(self, ev: FeatureEvent): new_color = ev.pick_info["new_data"] - self._line_world_object.material.color = pygfx.Color(new_color) + if np.unique(new_color, axis=0).shape[0] > 1: + raise ValueError("LegendError: LineGraphic colors no longer appropriate for legend") + + self._line_world_object.material.color = pygfx.Color(new_color[0]) def _update_thickness(self, ev: FeatureEvent): self._line_world_object.material.thickness = ev.pick_info["new_data"] + + +class Legend(Graphic): + def __init__(self, plot_area, *args, **kwargs): + """ + + Parameters + ---------- + plot_area: Union[Plot, Subplot, Dock] + plot area to put the legend in + + """ + self._graphics: List[Graphic] = list() + + # hex id of Graphic, i.e. graphic.loc are the keys + self._items: OrderedDict[str: LegendItem] = OrderedDict() + + super().__init__(**kwargs) + + group = pygfx.Group() + self._set_world_object(group) + + self._mesh = pygfx.Mesh( + pygfx.box_geometry(50, 10, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10) + ) + + self.world_object.add(self._mesh) + + plot_area.add_graphic(self) + + def graphics(self) -> Tuple[Graphic, ...]: + return tuple(self._graphics) + + def add_graphic(self, graphic: Graphic, label: str = None): + if isinstance(graphic, LineGraphic): + y_pos = len(self._items) * -10 + legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) + + self._mesh.geometry.positions.data[y_bottom, 1] += y_pos + self._mesh.geometry.positions.update_range() + + self.world_object.add(legend_item.world_object) + + else: + raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") + + self._items[graphic.loc] = legend_item + graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + + def remove_graphic(self, graphic: Graphic): + self._graphics.remove(graphic) + legend_item = self._items.pop(graphic.loc) + self.world_object.remove(legend_item.world_object) + + # figure out logic of removing items and re-ordering + # for i, (graphic_loc, legend_item) in enumerate(self._items.items()): + # pass + + def __getitem__(self, graphic: Graphic) -> LegendItem: + if not isinstance(graphic, Graphic): + raise TypeError("Must index Legend with Graphics") + + if graphic.loc not in self._items.keys(): + raise KeyError("Graphic not in legend") + + return self._items[graphic.loc] From de23dd91c03bf826941a806380904e92106ee63f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 20 Feb 2024 12:58:12 -0500 Subject: [PATCH 08/20] legend mesh resizes properly --- fastplotlib/legends/legend.py | 61 ++++++--------- fastplotlib/utils/mesh_masks.py | 128 ++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 38 deletions(-) create mode 100644 fastplotlib/utils/mesh_masks.py diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 266d13429..1a90ee370 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -5,39 +5,10 @@ import numpy as np import pygfx -from fastplotlib.graphics._base import Graphic -from fastplotlib.graphics._features._base import FeatureEvent -from fastplotlib.graphics import LineGraphic, ScatterGraphic, ImageGraphic - - -y_bottom = np.array( - [ - True, - False, - True, - False, - True, - False, - True, - False, - False, - False, - False, - False, - True, - True, - True, - True, - True, - True, - False, - False, - True, - True, - False, - False, - ] -) +from ..graphics._base import Graphic +from ..graphics._features._base import FeatureEvent +from ..graphics import LineGraphic, ScatterGraphic, ImageGraphic +from ..utils import mesh_masks class LegendItem: @@ -112,7 +83,7 @@ def __init__( material=material(thickness=graphic.thickness(), color=pygfx.Color(color)) ) - self._line_world_object.world.x = -20 + position[0] + self._line_world_object.world.x = self._line_world_object.world.x + position[0] self._line_world_object.world.y = self._line_world_object.world.y + position[1] self._label_world_object = pygfx.Text( @@ -128,7 +99,9 @@ def __init__( outline_thickness=0, ) ) - self._label_world_object.world.x = self._label_world_object.world.x - 10 + position[0] + + # add 10 to x to account for space for the line + self._label_world_object.world.x = self._label_world_object.world.x + position[0] + 10 self._label_world_object.world.y = self._label_world_object.world.y + position[1] self.world_object = pygfx.Group() @@ -172,6 +145,7 @@ def __init__(self, plot_area, *args, **kwargs): super().__init__(**kwargs) group = pygfx.Group() + self._legend_items_group = pygfx.Group() self._set_world_object(group) self._mesh = pygfx.Mesh( @@ -180,6 +154,7 @@ def __init__(self, plot_area, *args, **kwargs): ) self.world_object.add(self._mesh) + self.world_object.add(self._legend_items_group) plot_area.add_graphic(self) @@ -191,10 +166,9 @@ def add_graphic(self, graphic: Graphic, label: str = None): y_pos = len(self._items) * -10 legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) - self._mesh.geometry.positions.data[y_bottom, 1] += y_pos - self._mesh.geometry.positions.update_range() + self._legend_items_group.add(legend_item.world_object) - self.world_object.add(legend_item.world_object) + self._reset_mesh_dims() else: raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") @@ -202,6 +176,17 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._items[graphic.loc] = legend_item graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + def _reset_mesh_dims(self): + bbox = self._legend_items_group.get_world_bounding_box() + + width, height, _ = bbox.ptp(axis=0) + + self._mesh.geometry.positions.data[mesh_masks.x_right] = width + 7 + self._mesh.geometry.positions.data[mesh_masks.x_left] = -5 + self._mesh.geometry.positions.data[mesh_masks.y_bottom] = 0 + self._mesh.geometry.positions.data[mesh_masks.y_bottom] = -height - 3 + self._mesh.geometry.positions.update_range() + def remove_graphic(self, graphic: Graphic): self._graphics.remove(graphic) legend_item = self._items.pop(graphic.loc) diff --git a/fastplotlib/utils/mesh_masks.py b/fastplotlib/utils/mesh_masks.py new file mode 100644 index 000000000..600e5ab6d --- /dev/null +++ b/fastplotlib/utils/mesh_masks.py @@ -0,0 +1,128 @@ +import numpy as np + + +""" +positions for indexing the BoxGeometry to set the "width" and "size" of the box +hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +""" + +x_right = np.array( + [ + True, + True, + True, + True, + False, + False, + False, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + ] +) + +x_left = np.array( + [ + False, + False, + False, + False, + True, + True, + True, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + ] +) + +y_top = np.array( + [ + False, + True, + False, + True, + False, + True, + False, + True, + True, + True, + True, + True, + False, + False, + False, + False, + False, + False, + True, + True, + False, + False, + True, + True, + ] +) + +y_bottom = np.array( + [ + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + False, + False, + ] +) + +x_right = (x_right, 0) +x_left = (x_left, 0) +y_top = (y_top, 1) +y_bottom = (y_bottom, 1) \ No newline at end of file From e316c5eb29073828a100e61eebc554edb25bff40 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Feb 2024 01:29:42 -0500 Subject: [PATCH 09/20] remove legend items and reorder works --- fastplotlib/legends/legend.py | 48 +++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 1a90ee370..9af2b9e21 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -83,8 +83,7 @@ def __init__( material=material(thickness=graphic.thickness(), color=pygfx.Color(color)) ) - self._line_world_object.world.x = self._line_world_object.world.x + position[0] - self._line_world_object.world.y = self._line_world_object.world.y + position[1] + # self._line_world_object.world.x = position[0] self._label_world_object = pygfx.Text( geometry=pygfx.TextGeometry( @@ -100,12 +99,14 @@ def __init__( ) ) - # add 10 to x to account for space for the line - self._label_world_object.world.x = self._label_world_object.world.x + position[0] + 10 - self._label_world_object.world.y = self._label_world_object.world.y + position[1] - self.world_object = pygfx.Group() self.world_object.add(self._line_world_object, self._label_world_object) + + self.world_object.world.x = position[0] + # add 10 to x to account for space for the line + self._label_world_object.world.x = position[0] + 10 + + self.world_object.world.y = position[1] self.world_object.world.z = 2 @property @@ -162,6 +163,11 @@ def graphics(self) -> Tuple[Graphic, ...]: return tuple(self._graphics) def add_graphic(self, graphic: Graphic, label: str = None): + if graphic in self._graphics: + raise KeyError( + f"Graphic already exists in legend with label: '{self._items[graphic.loc].label}'" + ) + if isinstance(graphic, LineGraphic): y_pos = len(self._items) * -10 legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) @@ -173,6 +179,7 @@ def add_graphic(self, graphic: Graphic, label: str = None): else: raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") + self._graphics.append(graphic) self._items[graphic.loc] = legend_item graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) @@ -190,11 +197,32 @@ def _reset_mesh_dims(self): def remove_graphic(self, graphic: Graphic): self._graphics.remove(graphic) legend_item = self._items.pop(graphic.loc) - self.world_object.remove(legend_item.world_object) + self._legend_items_group.remove(legend_item.world_object) + self._reset_item_positions() + + def _reset_item_positions(self): + for i, (graphic_loc, legend_item) in enumerate(self._items.items()): + y_pos = i * -10 + legend_item.world_object.world.y = y_pos + + self._reset_mesh_dims() + + def reorder(self, labels: Iterable[str]): + all_labels = [legend_item.label for legend_item in self._items.values()] + + if not set(labels) == set(all_labels): + raise ValueError("Must pass all existing legend labels") + + new_items = OrderedDict() + + for label in labels: + for graphic_loc, legend_item in self._items.items(): + if label == legend_item.label: + new_items[graphic_loc] = self._items.pop(graphic_loc) + break - # figure out logic of removing items and re-ordering - # for i, (graphic_loc, legend_item) in enumerate(self._items.items()): - # pass + self._items = new_items + self._reset_item_positions() def __getitem__(self, graphic: Graphic) -> LegendItem: if not isinstance(graphic, Graphic): From b6e25317ce5d21b4c7d56413aaee02616f7e1ac3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Feb 2024 01:51:17 -0500 Subject: [PATCH 10/20] enforce all legend labels to be unique --- fastplotlib/legends/legend.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 9af2b9e21..ed9f8fda4 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -32,6 +32,7 @@ def __init__( class LineLegendItem(LegendItem): def __init__( self, + parent, graphic: LineGraphic, label: str, position: Tuple[int, int] @@ -61,6 +62,8 @@ def __init__( color = pygfx.Color(np.unique(graphic.colors(), axis=0).ravel()) + self._parent = parent + super().__init__(label, color) graphic.colors.add_event_handler(self._update_color) @@ -115,6 +118,7 @@ def label(self) -> str: @label.setter def label(self, text: str): + self._parent._check_label_unique(text) self._label_world_object.geometry.set_text(text) def _update_color(self, ev: FeatureEvent): @@ -162,15 +166,25 @@ def __init__(self, plot_area, *args, **kwargs): def graphics(self) -> Tuple[Graphic, ...]: return tuple(self._graphics) + def _check_label_unique(self, label): + for legend_item in self._items.values(): + if legend_item.label == label: + raise ValueError( + f"You have passed the label '{label}' which is already used for another legend item. " + f"All labels within a legend must be unique." + ) + def add_graphic(self, graphic: Graphic, label: str = None): if graphic in self._graphics: raise KeyError( f"Graphic already exists in legend with label: '{self._items[graphic.loc].label}'" ) + self._check_label_unique(label) + if isinstance(graphic, LineGraphic): y_pos = len(self._items) * -10 - legend_item = LineLegendItem(graphic, label, position=(0, y_pos)) + legend_item = LineLegendItem(self, graphic, label, position=(0, y_pos)) self._legend_items_group.add(legend_item.world_object) From a6743ccf7bdb0bdad3622c32be1881dc3b039846 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Feb 2024 20:12:36 -0500 Subject: [PATCH 11/20] add legends property to PlotArea --- fastplotlib/layouts/_plot_area.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 819efa205..d889d40d7 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -12,6 +12,7 @@ from ._utils import create_camera, create_controller from ..graphics._base import Graphic from ..graphics.selectors._base_selector import BaseSelector +from ..legends import Legend # dict to store Graphic instances # this is the only place where the real references to Graphics are stored in a Python session @@ -62,6 +63,9 @@ def __init__( name: str, optional name this ``subplot`` or ``plot`` +1:1 + + """ @@ -208,6 +212,8 @@ def graphics(self) -> Tuple[Graphic, ...]: proxies = list() for loc in self._graphics: p = weakref.proxy(GRAPHICS[loc]) + if p.__class__.__name__ == "Legend": + continue proxies.append(p) return tuple(proxies) @@ -222,6 +228,17 @@ def selectors(self) -> Tuple[BaseSelector, ...]: return tuple(proxies) + @property + def legends(self) -> Tuple[Legend, ...]: + """Legends in the plot area.""" + proxies = list() + for loc in self._graphics: + p = weakref.proxy(GRAPHICS[loc]) + if p.__class__.__name__ == "Legend": + proxies.append(p) + + return tuple(proxies) + @property def name(self) -> Any: """The name of this plot area""" @@ -486,6 +503,9 @@ def _check_graphic_name_exists(self, name): for s in self.selectors: graphic_names.append(s.name) + for l in self.legends: + graphic_names.append(l.name) + if name in graphic_names: raise ValueError( f"graphics must have unique names, current graphic names are:\n {graphic_names}" @@ -666,6 +686,10 @@ def __getitem__(self, name: str): if selector.name == name: return selector + for legend in self.legends: + if legend.name == name: + return legend + graphic_names = list() for g in self.graphics: graphic_names.append(g.name) @@ -681,7 +705,7 @@ def __getitem__(self, name: str): ) def __contains__(self, item: Union[str, Graphic]): - to_check = [*self.graphics, *self.selectors] + to_check = [*self.graphics, *self.selectors, *self.legends] if isinstance(item, Graphic): if item in to_check: From 1e639b4a99387d358f091e2266b680504e122fb4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Feb 2024 20:13:06 -0500 Subject: [PATCH 12/20] checks for Graphic name --- fastplotlib/graphics/_base.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index ae598ef3e..4f342faa2 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -63,14 +63,16 @@ def __init__( Parameters ---------- name: str, optional - name this graphic, makes it indexable within plots + name this graphic to use it as a key to access from the plot metadata: Any, optional metadata attached to this Graphic, this is for the user to manage """ + if (name is not None) and (not isinstance(name, str)): + raise TypeError("Graphic `name` must be of type ") - self.name = name + self._name = name self.metadata = metadata self.collection_index = collection_index self.registered_callbacks = dict() @@ -81,6 +83,20 @@ def __init__( self.deleted = Deleted(self, False) + self._plot_area = None + + @property + def name(self) -> Union[str, None]: + """str name reference for this item""" + return self._name + + @name.setter + def name(self, name: str): + if not isinstance(name, str): + raise TypeError("`Graphic` name must be of type ") + if self._plot_area is not None: + self._plot_area._check_graphic_name_exists(name) + @property def world_object(self) -> WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" From d5b4f5b162723b7035a9847a1f0ac83214531346 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Feb 2024 20:14:46 -0500 Subject: [PATCH 13/20] highlight linegraphic when legend item is clicked --- fastplotlib/legends/legend.py | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index ed9f8fda4..97a8aeda3 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -67,7 +67,6 @@ def __init__( super().__init__(label, color) graphic.colors.add_event_handler(self._update_color) - graphic.thickness.add_event_handler(self._update_thickness) # construct Line WorldObject data = np.array( @@ -76,14 +75,11 @@ def __init__( dtype=np.float32 ) - if graphic.thickness() < 1.1: - material = pygfx.LineThinMaterial - else: - material = pygfx.LineMaterial + material = pygfx.LineMaterial self._line_world_object = pygfx.Line( geometry=pygfx.Geometry(positions=data), - material=material(thickness=graphic.thickness(), color=pygfx.Color(color)) + material=material(thickness=8, color=self._color) ) # self._line_world_object.world.x = position[0] @@ -112,6 +108,8 @@ def __init__( self.world_object.world.y = position[1] self.world_object.world.z = 2 + self.world_object.add_event_handler(partial(self._highlight_graphic, graphic), "click") + @property def label(self) -> str: return self._label @@ -126,14 +124,29 @@ def _update_color(self, ev: FeatureEvent): if np.unique(new_color, axis=0).shape[0] > 1: raise ValueError("LegendError: LineGraphic colors no longer appropriate for legend") - self._line_world_object.material.color = pygfx.Color(new_color[0]) + self._color = new_color[0] + self._line_world_object.material.color = pygfx.Color(self._color) - def _update_thickness(self, ev: FeatureEvent): - self._line_world_object.material.thickness = ev.pick_info["new_data"] + def _highlight_graphic(self, graphic, ev): + graphic_color = pygfx.Color(np.unique(graphic.colors(), axis=0).ravel()) + + if graphic_color == self._parent.highlight_color: + graphic.colors = self._color + else: + # hacky but fine for now + orig_color = pygfx.Color(self._color) + graphic.colors = self._parent.highlight_color + self._color = orig_color class Legend(Graphic): - def __init__(self, plot_area, *args, **kwargs): + def __init__( + self, + plot_area, + highlight_color: Union[str, tuple, np.ndarray] = "w", + *args, + **kwargs + ): """ Parameters @@ -141,6 +154,9 @@ def __init__(self, plot_area, *args, **kwargs): plot_area: Union[Plot, Subplot, Dock] plot area to put the legend in + highlight_color: Union[str, tuple, np.ndarray], default "w" + highlight color + """ self._graphics: List[Graphic] = list() @@ -161,6 +177,8 @@ def __init__(self, plot_area, *args, **kwargs): self.world_object.add(self._mesh) self.world_object.add(self._legend_items_group) + self.highlight_color = pygfx.Color(highlight_color) + plot_area.add_graphic(self) def graphics(self) -> Tuple[Graphic, ...]: From bcae1f2854060b12c01ccadc918d0eb3fa473d8d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 22 Feb 2024 00:16:32 -0500 Subject: [PATCH 14/20] legend is moveable --- fastplotlib/legends/legend.py | 41 ++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 97a8aeda3..8b1e372f6 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -179,7 +179,16 @@ def __init__( self.highlight_color = pygfx.Color(highlight_color) - plot_area.add_graphic(self) + self._plot_area = plot_area + self._plot_area.add_graphic(self) + + # TODO: refactor with "moveable graphic" base class once that's done + self._mesh.add_event_handler(self._pointer_down, "pointer_down") + self._plot_area.renderer.add_event_handler(self._pointer_move, "pointer_move") + self._plot_area.renderer.add_event_handler(self._pointer_up, "pointer_up") + + self._last_position = None + self._initial_controller_state = self._plot_area.controller.enabled def graphics(self) -> Tuple[Graphic, ...]: return tuple(self._graphics) @@ -256,6 +265,36 @@ def reorder(self, labels: Iterable[str]): self._items = new_items self._reset_item_positions() + def _pointer_down(self, ev): + self._last_position = self._plot_area.map_screen_to_world(ev) + self._initial_controller_state = self._plot_area.controller.enabled + + def _pointer_move(self, ev): + if self._last_position is None: + return + + self._plot_area.controller.enabled = False + + world_pos = self._plot_area.map_screen_to_world(ev) + + # outside viewport + if world_pos is None: + return + + delta = world_pos - self._last_position + + self.world_object.world.x = self.world_object.world.x + delta[0] + self.world_object.world.y = self.world_object.world.y + delta[1] + + self._last_position = world_pos + + self._plot_area.controller.enabled = self._initial_controller_state + + def _pointer_up(self, ev): + self._last_position = None + if self._initial_controller_state is not None: + self._plot_area.controller.enabled = self._initial_controller_state + def __getitem__(self, graphic: Graphic) -> LegendItem: if not isinstance(graphic, Graphic): raise TypeError("Must index Legend with Graphics") From b7e6fc5215d9fa2a3b5bc44e40337f8050246e73 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 22 Feb 2024 14:11:04 -0500 Subject: [PATCH 15/20] remove weird characters that were committed for some reason --- fastplotlib/layouts/_plot_area.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index d889d40d7..cc7526398 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -63,10 +63,7 @@ def __init__( name: str, optional name this ``subplot`` or ``plot`` -1:1 - - - + """ self._parent: PlotArea = parent From 80cd45934985a8424078ff5ec76bd6f604641901 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 23 Feb 2024 17:23:03 -0500 Subject: [PATCH 16/20] progress on legend grid placement, not yet working --- fastplotlib/legends/legend.py | 63 +++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 8b1e372f6..69dc1f799 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -144,6 +144,8 @@ def __init__( self, plot_area, highlight_color: Union[str, tuple, np.ndarray] = "w", + max_rows: int = None, + max_cols: int = None, *args, **kwargs ): @@ -157,6 +159,12 @@ def __init__( highlight_color: Union[str, tuple, np.ndarray], default "w" highlight color + max_rows: int, default None + maximum number of rows allowed in the legend, specify either ``max_rows`` or ``max_cols`` + + max_cols: int, default ``1`` + maximum number of columns allowed in the legend, specify either ``max_rows`` or ``max_cols`` + """ self._graphics: List[Graphic] = list() @@ -190,6 +198,15 @@ def __init__( self._last_position = None self._initial_controller_state = self._plot_area.controller.enabled + if max_cols is None and max_rows is None: + max_cols = 1 + + self._max_rows = max_rows + self._max_cols = max_cols + + self._current_row_ix = 0 + self._current_col_ix = 0 + def graphics(self) -> Tuple[Graphic, ...]: return tuple(self._graphics) @@ -209,21 +226,53 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._check_label_unique(label) - if isinstance(graphic, LineGraphic): - y_pos = len(self._items) * -10 - legend_item = LineLegendItem(self, graphic, label, position=(0, y_pos)) - - self._legend_items_group.add(legend_item.world_object) - - self._reset_mesh_dims() + new_col_ix = self._current_col_ix + new_row_ix = self._current_row_ix + + x_pos = 0 + y_pos = 0 + + if self._max_cols is None: + if self._current_row_ix == self._max_rows: + # get x position offset + # get largest x_val from bbox of previous column bboxes + new_col_ix = self._current_col_ix + 1 + prev_column_items: List[LegendItem] = list(self._items.values())[-self._max_rows:] + x_pos = prev_column_items[-1].world_object.world.x + for item in prev_column_items: + bbox = item.world_object.get_world_bounding_box() + width, height, depth = bbox.ptp(axis=0) + x_pos = max(x_pos, width) + + new_row_ix = 0 + else: + y_pos = self._current_row_ix * -10 + new_row_ix = self._current_row_ix + 1 + + # print(new_row_ix) + print(new_col_ix) + + elif self._max_rows is None: + if self._current_col_ix == self._max_cols: + # set height for now, move down by 10 + y_pos = self._current_row_ix * -10 + new_row_ix = self._current_row_ix + 1 + if isinstance(graphic, LineGraphic): + legend_item = LineLegendItem(self, graphic, label, position=(x_pos, y_pos)) else: raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") + self._legend_items_group.add(legend_item.world_object) + self._reset_mesh_dims() + self._graphics.append(graphic) self._items[graphic.loc] = legend_item graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + self._current_col_ix = new_col_ix + self._current_row_ix = new_row_ix + def _reset_mesh_dims(self): bbox = self._legend_items_group.get_world_bounding_box() From 142c98958cf375d495aa1f60c80a458f10aa4f32 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 27 Feb 2024 00:21:49 -0500 Subject: [PATCH 17/20] max_rows works for legend --- fastplotlib/legends/legend.py | 41 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 69dc1f799..394ef1cbb 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -144,7 +144,7 @@ def __init__( self, plot_area, highlight_color: Union[str, tuple, np.ndarray] = "w", - max_rows: int = None, + max_rows: int = 5, max_cols: int = None, *args, **kwargs @@ -204,8 +204,8 @@ def __init__( self._max_rows = max_rows self._max_cols = max_cols - self._current_row_ix = 0 - self._current_col_ix = 0 + self._row_counter = 0 + self._col_counter = 0 def graphics(self) -> Tuple[Graphic, ...]: return tuple(self._graphics) @@ -226,42 +226,47 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._check_label_unique(label) - new_col_ix = self._current_col_ix - new_row_ix = self._current_row_ix + new_col_ix = self._col_counter + new_row_ix = self._row_counter x_pos = 0 y_pos = 0 if self._max_cols is None: - if self._current_row_ix == self._max_rows: + if self._row_counter == self._max_rows: + # set counters + new_col_ix = self._col_counter + 1 + # get x position offset # get largest x_val from bbox of previous column bboxes - new_col_ix = self._current_col_ix + 1 prev_column_items: List[LegendItem] = list(self._items.values())[-self._max_rows:] x_pos = prev_column_items[-1].world_object.world.x + max_width = 0 for item in prev_column_items: bbox = item.world_object.get_world_bounding_box() width, height, depth = bbox.ptp(axis=0) - x_pos = max(x_pos, width) + max_width = max(max_width, width) + x_pos = x_pos + max_width + 15 # add 15 for spacing - new_row_ix = 0 + # rest row index for next iteration + new_row_ix = 1 else: - y_pos = self._current_row_ix * -10 - new_row_ix = self._current_row_ix + 1 + if len(self._items) > 0: + x_pos = list(self._items.values())[-1].world_object.world.x + + y_pos = new_row_ix * -10 + new_row_ix = self._row_counter + 1 # print(new_row_ix) print(new_col_ix) elif self._max_rows is None: - if self._current_col_ix == self._max_cols: - # set height for now, move down by 10 - y_pos = self._current_row_ix * -10 - new_row_ix = self._current_row_ix + 1 + raise NotImplemented if isinstance(graphic, LineGraphic): legend_item = LineLegendItem(self, graphic, label, position=(x_pos, y_pos)) else: - raise ValueError("Legend only supported for LineGraphic and ScatterGraphic") + raise ValueError("Legend only supported for LineGraphic for now.") self._legend_items_group.add(legend_item.world_object) self._reset_mesh_dims() @@ -270,8 +275,8 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._items[graphic.loc] = legend_item graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) - self._current_col_ix = new_col_ix - self._current_row_ix = new_row_ix + self._col_counter = new_col_ix + self._row_counter = new_row_ix def _reset_mesh_dims(self): bbox = self._legend_items_group.get_world_bounding_box() From c2122e8793b3188fe1c9aafd914d4deb3f39759c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 27 Feb 2024 10:24:16 -0500 Subject: [PATCH 18/20] just allow max_rows kwarg for legends, no cols --- fastplotlib/legends/legend.py | 67 ++++++++++++++--------------------- 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 394ef1cbb..f6f398865 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -145,7 +145,6 @@ def __init__( plot_area, highlight_color: Union[str, tuple, np.ndarray] = "w", max_rows: int = 5, - max_cols: int = None, *args, **kwargs ): @@ -159,11 +158,8 @@ def __init__( highlight_color: Union[str, tuple, np.ndarray], default "w" highlight color - max_rows: int, default None - maximum number of rows allowed in the legend, specify either ``max_rows`` or ``max_cols`` - - max_cols: int, default ``1`` - maximum number of columns allowed in the legend, specify either ``max_rows`` or ``max_cols`` + max_rows: int, default 5 + maximum number of rows allowed in the legend """ self._graphics: List[Graphic] = list() @@ -171,7 +167,7 @@ def __init__( # hex id of Graphic, i.e. graphic.loc are the keys self._items: OrderedDict[str: LegendItem] = OrderedDict() - super().__init__(**kwargs) + super().__init__(*args, **kwargs) group = pygfx.Group() self._legend_items_group = pygfx.Group() @@ -198,11 +194,7 @@ def __init__( self._last_position = None self._initial_controller_state = self._plot_area.controller.enabled - if max_cols is None and max_rows is None: - max_cols = 1 - self._max_rows = max_rows - self._max_cols = max_cols self._row_counter = 0 self._col_counter = 0 @@ -232,36 +224,29 @@ def add_graphic(self, graphic: Graphic, label: str = None): x_pos = 0 y_pos = 0 - if self._max_cols is None: - if self._row_counter == self._max_rows: - # set counters - new_col_ix = self._col_counter + 1 - - # get x position offset - # get largest x_val from bbox of previous column bboxes - prev_column_items: List[LegendItem] = list(self._items.values())[-self._max_rows:] - x_pos = prev_column_items[-1].world_object.world.x - max_width = 0 - for item in prev_column_items: - bbox = item.world_object.get_world_bounding_box() - width, height, depth = bbox.ptp(axis=0) - max_width = max(max_width, width) - x_pos = x_pos + max_width + 15 # add 15 for spacing - - # rest row index for next iteration - new_row_ix = 1 - else: - if len(self._items) > 0: - x_pos = list(self._items.values())[-1].world_object.world.x - - y_pos = new_row_ix * -10 - new_row_ix = self._row_counter + 1 - - # print(new_row_ix) - print(new_col_ix) - - elif self._max_rows is None: - raise NotImplemented + if self._row_counter == self._max_rows: + # set counters + new_col_ix = self._col_counter + 1 + + # get x position offset + # get largest x_val from bbox of previous column bboxes + prev_column_items: List[LegendItem] = list(self._items.values())[-self._max_rows:] + x_pos = prev_column_items[-1].world_object.world.x + max_width = 0 + for item in prev_column_items: + bbox = item.world_object.get_world_bounding_box() + width, height, depth = bbox.ptp(axis=0) + max_width = max(max_width, width) + x_pos = x_pos + max_width + 15 # add 15 for spacing + + # rest row index for next iteration + new_row_ix = 1 + else: + if len(self._items) > 0: + x_pos = list(self._items.values())[-1].world_object.world.x + + y_pos = new_row_ix * -10 + new_row_ix = self._row_counter + 1 if isinstance(graphic, LineGraphic): legend_item = LineLegendItem(self, graphic, label, position=(x_pos, y_pos)) From 9609d8c319da426fc548c38b6c79a2425292c855 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 27 Feb 2024 10:42:04 -0500 Subject: [PATCH 19/20] line that snuck in from another branch --- fastplotlib/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 4b6a743a3..ca872f4e4 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -23,8 +23,6 @@ "No WGPU adapters found, fastplotlib will not work." ) -_notebook_print_banner() - with open(Path(__file__).parent.joinpath("VERSION"), "r") as f: __version__ = f.read().split("\n")[0] From 29f646d2b32c5790f989af05ecca0ddf6746a233 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 28 Feb 2024 23:15:21 -0500 Subject: [PATCH 20/20] small changes --- fastplotlib/legends/legend.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index f6f398865..e29665e0f 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -57,6 +57,7 @@ def __init__( else: raise ValueError("Must specify `label` or Graphic must have a `name` to auto-use as the label") + # for now only support lines with a single color if np.unique(graphic.colors(), axis=0).shape[0] > 1: raise ValueError("Use colorbars for multi-colored lines, not legends") @@ -186,6 +187,10 @@ def __init__( self._plot_area = plot_area self._plot_area.add_graphic(self) + if self._plot_area.__class__.__name__ == "Dock": + if self._plot_area.size < 1: + self._plot_area.size = 100 + # TODO: refactor with "moveable graphic" base class once that's done self._mesh.add_event_handler(self._pointer_down, "pointer_down") self._plot_area.renderer.add_event_handler(self._pointer_move, "pointer_move") @@ -228,15 +233,19 @@ def add_graphic(self, graphic: Graphic, label: str = None): # set counters new_col_ix = self._col_counter + 1 - # get x position offset - # get largest x_val from bbox of previous column bboxes + # get x position offset for this new column of LegendItems + # start by getting the LegendItems in the previous column prev_column_items: List[LegendItem] = list(self._items.values())[-self._max_rows:] + # x position of LegendItems in previous column x_pos = prev_column_items[-1].world_object.world.x max_width = 0 + # get width of widest LegendItem in previous column to add to x_pos offset for this column for item in prev_column_items: bbox = item.world_object.get_world_bounding_box() width, height, depth = bbox.ptp(axis=0) max_width = max(max_width, width) + + # x position offset for this new column x_pos = x_pos + max_width + 15 # add 15 for spacing # rest row index for next iteration @@ -258,6 +267,7 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._graphics.append(graphic) self._items[graphic.loc] = legend_item + graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) self._col_counter = new_col_ix