From c6308e340ae07e58c921c888514b4acecd74c64b Mon Sep 17 00:00:00 2001 From: apasarkar Date: Thu, 11 Jun 2026 13:03:02 +0800 Subject: [PATCH 1/6] Full selection vector implementation --- .../graphics/selectors/_highlight_selector.py | 2 +- .../graphics/selectors/_selection_vector.py | 95 +++++++++++-------- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index 3ba08a676..cc72d5646 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -681,7 +681,7 @@ def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: return {k: tuple(v) for k, v in self._selection.items()} @selection.setter - def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"], list]) -> None: + def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"], list] | None) -> None: if self._selection_options is not None: if value is None: self._selected_indices = list() diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index a1e0bed10..c3dc5f854 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -1,21 +1,24 @@ from collections.abc import Callable from functools import partial from typing import Any, Sequence - +import numpy as np from ._protocols import SelectorProtocol, MultiSelectorProtocol - def identity(val: Any) -> Any: return val - class SelectionVector: + """ + A class for performing coordinated selections across multiple selectors. + The user specifies the selectors (via add_selector) and mappings from global selection indices to each individual selector's local indices. + """ def __init__(self, max_size: int = None): # selector -> (map, map_inv) self._selectors: dict[ - SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable] + SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable, list] ] = dict() self._selection: list[Any] = list() + self._block_reentrance = False @property def selection(self) -> tuple[Any]: @@ -23,67 +26,83 @@ def selection(self) -> tuple[Any]: @selection.setter def selection(self, new: Sequence[Any]): - # iterate through each selector that operates in its own "local" space - for selector_local, (map_, map_inv) in self._selectors.items(): - indices_local = map_(new) - selector_local.selection = indices_local + if self._block_reentrance: + return + else: + self._block_reentrance = True + # iterate through each selector that operates in its own "local" space + for selector_local, (map_, map_inv) in self._selectors.items(): + indices_local = map_(new) + selector_local.selection = indices_local + self._block_reentrance = False def append(self, index): self._selection.append(index) - for selector, (map_, map_inv) in self._selectors.items(): + for selector, (map_, map_inv, handler_list) in self._selectors.items(): if not isinstance(selector, MultiSelectorProtocol): continue index_local = map_([index]) - selector.append(index_local[0]) - - def clear(self): - self._selection.clear() - # TODO: clear selectors + selector.append(index_local) def add_selector( self, new: ( SelectorProtocol - | tuple[SelectorProtocol, Callable] - | tuple[SelectorProtocol, Callable, Callable] + | tuple[SelectorProtocol, np.ndarray | dict[int, int]] ), ): + """ + User specifies (1) the selector and (2) The master --> local index mapping. This + mapping is given either as: + - A 1D np.ndarray of integers. The array index is the global index, and the array value is the local index + - A dictionary where keys (master indices) and values (local indices) are both integers + """ selector: SelectorProtocol - map_: Callable - map_inv: Callable - if isinstance(new, (tuple, list)): if not isinstance(new[0], SelectorProtocol): raise TypeError - if len(new) not in (2, 3): - raise TypeError - - if not all(callable(c) for c in new[1:]): + if len(new) != 2: raise TypeError selector = new[0] - map_ = new[1] - map_inv = new[2] if len(new) == 3 else identity + master_to_local = new[1] + if isinstance(master_to_local, np.ndarray): + if not master_to_local.ndim == 1: + raise ValueError("If you pass in an array mapping, it must be 1-D") + master_to_local = dict(enumerate(master_to_local)) + + ## Construct inverse mapping + inverse_dict = dict() + for key, val in master_to_local.items(): + inverse_dict[val] = key + + ## Define the partial functions + master_to_local_map = lambda x:master_to_local[x] if x in master_to_local else None + local_to_master_map = lambda x:inverse_dict[x] if x in inverse_dict else None elif isinstance(new, SelectorProtocol): - selector, map_, map_inv = new, identity, identity + selector, master_to_local_map, local_to_master_map = new, identity, identity else: raise ValueError - selector.add_event_handler(partial(self._inv_handler, map_inv)) - - self._selectors[selector] = (map_, map_inv) + handler = selector.add_event_handler(partial(self._inv_handler, local_to_master_map)) + self._selectors[selector] = (master_to_local_map, local_to_master_map, [handler]) def _inv_handler(self, map_inv: Callable, local_selection): - return - # when a selectable changes its selection, set global index change using map inverse - # self._selection = map_inv(local_selection) - - def remove(self): - pass - - def clear_selectables(self): - self._selectors.clear() + self._selection = map_inv(local_selection) + + def remove_selector(self, selector: SelectorProtocol | MultiSelectorProtocol): + if selector in self._selectors: + map, map_inv, handler_list = self._selectors.pop(selector) + for handler in handler_list: + selector.remove_event_handler(handler) + if isinstance(selector, MultiSelectorProtocol): + selector.clear() + + def clear_selectors(self): + for selector in self._selectors.keys(): + if isinstance(selector, MultiSelectorProtocol): + selector.clear() \ No newline at end of file From 393b48d5cb7ba86bc56969a6ad336b7b21d3cb95 Mon Sep 17 00:00:00 2001 From: apasarkar Date: Thu, 11 Jun 2026 13:11:39 +0800 Subject: [PATCH 2/6] Includes some documentation at top of SelectionVector --- fastplotlib/graphics/selectors/_selection_vector.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index c3dc5f854..19024227e 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -10,7 +10,10 @@ def identity(val: Any) -> Any: class SelectionVector: """ A class for performing coordinated selections across multiple selectors. - The user specifies the selectors (via add_selector) and mappings from global selection indices to each individual selector's local indices. + For each selector in the selection vector, the user specifies how the global indices (shared across selectors) + maps to the local indices. + + The SelectionVector manages everything else, including the coordinated updating of indices whenever a selection changes """ def __init__(self, max_size: int = None): # selector -> (map, map_inv) From e80f9634fdc73c40c60de5b81d3792834d7f8029 Mon Sep 17 00:00:00 2001 From: apasarkar Date: Fri, 12 Jun 2026 04:04:12 +0800 Subject: [PATCH 3/6] Minor typing fix in linear selector selection setter --- fastplotlib/graphics/selectors/_linear.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 4ea454ee8..f652a3d9e 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -27,7 +27,7 @@ def selection(self) -> float: return self._selection.value @selection.setter - def selection(self, value: int): + def selection(self, value: float): graphic = self._parent if isinstance(graphic, GraphicCollection): From 174f861dac29c19bdbf10b76347deadcc03b304b Mon Sep 17 00:00:00 2001 From: apasarkar Date: Fri, 12 Jun 2026 09:08:18 +0800 Subject: [PATCH 4/6] First working version with selection vector --- .../graphics/selectors/_selection_vector.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index 19024227e..da68fffa5 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -1,8 +1,10 @@ from collections.abc import Callable from functools import partial from typing import Any, Sequence +from numbers import Integral import numpy as np from ._protocols import SelectorProtocol, MultiSelectorProtocol +from fastplotlib.graphics.features._base import GraphicFeatureEvent def identity(val: Any) -> Any: return val @@ -28,15 +30,22 @@ def selection(self) -> tuple[Any]: return tuple(self._selection) @selection.setter - def selection(self, new: Sequence[Any]): + def selection(self, new: Integral | Sequence[Any]): if self._block_reentrance: return else: self._block_reentrance = True + if isinstance(new, Integral): + new = [new] + self._selection = [i for i in new] # iterate through each selector that operates in its own "local" space - for selector_local, (map_, map_inv) in self._selectors.items(): - indices_local = map_(new) - selector_local.selection = indices_local + for selector_local, (map_, map_inv, handler) in self._selectors.items(): + cumulated_output = [] + for value in new: + curr_indices = map_(value) + cumulated_output.append(curr_indices) + # indices_local = map_(new) + selector_local.selection = cumulated_output self._block_reentrance = False def append(self, index): @@ -79,11 +88,11 @@ def add_selector( ## Construct inverse mapping inverse_dict = dict() for key, val in master_to_local.items(): - inverse_dict[val] = key + inverse_dict[int(val)] = int(key) ## Define the partial functions - master_to_local_map = lambda x:master_to_local[x] if x in master_to_local else None - local_to_master_map = lambda x:inverse_dict[x] if x in inverse_dict else None + master_to_local_map = lambda x:master_to_local[int(x)] if int(x) in master_to_local else None + local_to_master_map = lambda x:inverse_dict[int(x)] if x in inverse_dict and x is not None else None elif isinstance(new, SelectorProtocol): selector, master_to_local_map, local_to_master_map = new, identity, identity @@ -94,8 +103,15 @@ def add_selector( handler = selector.add_event_handler(partial(self._inv_handler, local_to_master_map)) self._selectors[selector] = (master_to_local_map, local_to_master_map, [handler]) - def _inv_handler(self, map_inv: Callable, local_selection): - self._selection = map_inv(local_selection) + def _inv_handler(self, map_inv: Callable, local_selection: dict | GraphicFeatureEvent): + if isinstance(local_selection, dict): + input_to_map = local_selection['value'] + # local_selection = list(local_selection.items())[0][1] + elif isinstance(local_selection, GraphicFeatureEvent): + input_to_map = local_selection.info['value'] + else: + raise ValueError("Input to inverse handler should either be dictionary or GraphicFeatureEvent") + self.selection = [map_inv(input_to_map[i]) for i in range(len(input_to_map))] def remove_selector(self, selector: SelectorProtocol | MultiSelectorProtocol): if selector in self._selectors: From 3a4dfdee58d52cf8e6161cb04e627f565ade08c5 Mon Sep 17 00:00:00 2001 From: apasarkar Date: Sat, 13 Jun 2026 12:50:38 +0800 Subject: [PATCH 5/6] Reworks the logic for adding selectors, improves some documentation, adds partial instead of lambda functions, improves typing in highlight selector --- .../graphics/selectors/_highlight_selector.py | 2 +- .../graphics/selectors/_selection_vector.py | 111 ++++++++++++------ 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index cc72d5646..dc757c07a 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -681,7 +681,7 @@ def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: return {k: tuple(v) for k, v in self._selection.items()} @selection.setter - def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"], list] | None) -> None: + def selection(self, value: Iterable[int | None] | dict[Literal["rows", "cols", "pixels"], list] | None) -> None: if self._selection_options is not None: if value is None: self._selected_indices = list() diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index da68fffa5..5da3ddf98 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -1,26 +1,54 @@ from collections.abc import Callable from functools import partial -from typing import Any, Sequence +from typing import Any, Sequence, TypeAlias from numbers import Integral + import numpy as np + from ._protocols import SelectorProtocol, MultiSelectorProtocol -from fastplotlib.graphics.features._base import GraphicFeatureEvent + +Mapping: TypeAlias = np.ndarray | dict[int, int] | Callable def identity(val: Any) -> Any: return val +def array_map(arr: np.ndarray, index: Integral): + """ + Used to map local to global indices + """ + return None if np.isnan(arr[index]) else arr[index] + +def inv_array_map(arr: np.ndarray, + value: int) -> None | Integral: + """ + arr[i] gives the global index + """ + x = np.flatnonzero(arr == value) + return None if x.size == 0 else x[0] + +def dict_map(my_dict: dict, key: Integral): + if key is None: + return None + elif int(key) not in my_dict: + return None + else: + return my_dict[key] + + class SelectionVector: """ A class for performing coordinated selections across multiple selectors. For each selector in the selection vector, the user specifies how the global indices (shared across selectors) - maps to the local indices. + maps to the local indices (each selector has its own local index space). - The SelectionVector manages everything else, including the coordinated updating of indices whenever a selection changes + The SelectionVector coordinates across individual selectors, including the coordinated updating of indices whenever a selection changes """ def __init__(self, max_size: int = None): # selector -> (map, map_inv) + + ## Key is a selector, value is a (1) local to global index map (2) global to local index map (3) list of event handlers self._selectors: dict[ - SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable, list] + SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable, list[Callable]] ] = dict() self._selection: list[Any] = list() self._block_reentrance = False @@ -40,12 +68,11 @@ def selection(self, new: Integral | Sequence[Any]): self._selection = [i for i in new] # iterate through each selector that operates in its own "local" space for selector_local, (map_, map_inv, handler) in self._selectors.items(): - cumulated_output = [] + local_indices = [] for value in new: curr_indices = map_(value) - cumulated_output.append(curr_indices) - # indices_local = map_(new) - selector_local.selection = cumulated_output + local_indices.append(curr_indices) + selector_local.selection = local_indices self._block_reentrance = False def append(self, index): @@ -54,14 +81,16 @@ def append(self, index): if not isinstance(selector, MultiSelectorProtocol): continue - index_local = map_([index]) + index_local = map_(index) selector.append(index_local) def add_selector( self, new: ( SelectorProtocol - | tuple[SelectorProtocol, np.ndarray | dict[int, int]] + | tuple[SelectorProtocol, dict] + | tuple[SelectorProtocol, np.ndarray] + |tuple[SelectorProtocol, Callable, Callable] ), ): """ @@ -69,48 +98,52 @@ def add_selector( mapping is given either as: - A 1D np.ndarray of integers. The array index is the global index, and the array value is the local index - A dictionary where keys (master indices) and values (local indices) are both integers + - Two callables. The first callable defines the global index --> local index map, the second specifies the local index --> global index map. """ - selector: SelectorProtocol if isinstance(new, (tuple, list)): if not isinstance(new[0], SelectorProtocol): raise TypeError - if len(new) != 2: - raise TypeError + if len(new) == 3: + if isinstance(new[1], Callable) and isinstance(new[2], Callable): + master_to_local = new[1] + local_to_master = new[2] + else: + raise ValueError(f"Both index mappings must be Callables, you provided {type(new[1])} and {type(new[2])}") + elif len(new) == 2: + if isinstance(new[1], dict): + ## Construct inverse mapping + inverse_dict = dict() + for key, val in new[1].items(): + inverse_dict[int(val)] = int(key) + master_to_local = partial(dict_map, new[1]) + local_to_master = partial(dict_map, inverse_dict) + + elif isinstance(new[1], np.ndarray): + if not new[1].ndim == 1: + raise ValueError("If you pass in an array mapping, it must be 1-D") + master_to_local = partial(array_map, new[1]) + local_to_master = partial(inv_array_map, new[1]) + else: + raise ValueError(f"Must either provide a single dict or numpy array specifying the local to global index mapping, or two callables" + f"specifying the mapping in both directions") selector = new[0] - master_to_local = new[1] - if isinstance(master_to_local, np.ndarray): - if not master_to_local.ndim == 1: - raise ValueError("If you pass in an array mapping, it must be 1-D") - master_to_local = dict(enumerate(master_to_local)) - - ## Construct inverse mapping - inverse_dict = dict() - for key, val in master_to_local.items(): - inverse_dict[int(val)] = int(key) - - ## Define the partial functions - master_to_local_map = lambda x:master_to_local[int(x)] if int(x) in master_to_local else None - local_to_master_map = lambda x:inverse_dict[int(x)] if x in inverse_dict and x is not None else None elif isinstance(new, SelectorProtocol): - selector, master_to_local_map, local_to_master_map = new, identity, identity + selector, master_to_local, local_to_master = new, identity, identity else: raise ValueError - handler = selector.add_event_handler(partial(self._inv_handler, local_to_master_map)) - self._selectors[selector] = (master_to_local_map, local_to_master_map, [handler]) + handler = selector.add_event_handler(partial(self._inv_handler, local_to_master)) + self._selectors[selector] = (master_to_local, local_to_master, [handler]) - def _inv_handler(self, map_inv: Callable, local_selection: dict | GraphicFeatureEvent): - if isinstance(local_selection, dict): - input_to_map = local_selection['value'] - # local_selection = list(local_selection.items())[0][1] - elif isinstance(local_selection, GraphicFeatureEvent): - input_to_map = local_selection.info['value'] - else: - raise ValueError("Input to inverse handler should either be dictionary or GraphicFeatureEvent") + def _inv_handler(self, map_inv: Callable, local_selection: dict): + """ + HighlightSelector and VisibilitySelector emit a dictionary with keys selector and value + """ + input_to_map = local_selection['value'] self.selection = [map_inv(input_to_map[i]) for i in range(len(input_to_map))] def remove_selector(self, selector: SelectorProtocol | MultiSelectorProtocol): From 701250d780f14ea031e0c2869977c6464dd5fc4d Mon Sep 17 00:00:00 2001 From: apasarkar Date: Sat, 13 Jun 2026 13:01:12 +0800 Subject: [PATCH 6/6] Fixes casting bug in the integer version of the code --- fastplotlib/graphics/selectors/_selection_vector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index 5da3ddf98..10b2885ea 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -16,7 +16,7 @@ def array_map(arr: np.ndarray, index: Integral): """ Used to map local to global indices """ - return None if np.isnan(arr[index]) else arr[index] + return None if np.isnan(arr[index]) else int(arr[index]) def inv_array_map(arr: np.ndarray, value: int) -> None | Integral: @@ -24,7 +24,7 @@ def inv_array_map(arr: np.ndarray, arr[i] gives the global index """ x = np.flatnonzero(arr == value) - return None if x.size == 0 else x[0] + return None if x.size == 0 else int(x[0]) def dict_map(my_dict: dict, key: Integral): if key is None: @@ -99,6 +99,7 @@ def add_selector( - A 1D np.ndarray of integers. The array index is the global index, and the array value is the local index - A dictionary where keys (master indices) and values (local indices) are both integers - Two callables. The first callable defines the global index --> local index map, the second specifies the local index --> global index map. + All callables take as input nonnegative integers and output nonnegative integers. """ if isinstance(new, (tuple, list)): if not isinstance(new[0], SelectorProtocol):