from __future__ import annotations
from collections.abc import Callable, Hashable, Sequence
from functools import partial
from typing import Any, Type, TYPE_CHECKING
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from numpy.typing import ArrayLike
from ....graphics import (
LineStack,
LineCollection,
ScatterCollection,
ScatterStack,
)
from .._base import (
NDSlicer,
NDGraphic,
WindowFuncCallable,
get_supported_kwargs,
transpose,
)
from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy
from .._index import ReferenceIndices
from .._async import run_in_thread_pool, run_sync
if TYPE_CHECKING:
from .._ndw_subplot import NDWSubplot
# types for the other features
FeatureCallable = Callable[[np.ndarray, slice], np.ndarray]
ColorsType = str | Sequence[str] | np.ndarray | FeatureCallable | None
MarkersType = str | Sequence[str] | np.ndarray | FeatureCallable | None
SizesType = float | Sequence[float] | np.ndarray | FeatureCallable | None
[docs]
class NDPositionsSlicer(NDSlicer):
def __init__(
self,
data: Any,
dims: Sequence[str],
# TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic
display_dims: tuple[
str | None, str, str
], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!!
slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None,
display_window: int | float | None = 100, # window for n_datapoints dim only
max_display_datapoints: int | None = 1_000,
datapoints_window_func: tuple[Callable, str, int | float] | None = None,
**kwargs,
):
"""
``NDSlicer`` subclass for n-dimensional positional and timeseries data.
Produces ``[n_graphics, p, <value dim>]`` slices for a ``LineCollection``, ``LineStack``,
``ScatterCollection``, or ``ScatterStack``, where ``p`` is the datapoints dim.
The ``p`` dim is simultaneously a slider dim and a spatial dim. Rather than the general ``window_funcs``
mechanism, it is windowed by :attr:`display_window`, which selects the datapoints that are rendered, and
by :attr:`datapoints_window_func`, which aggregates over them.
Parameters
----------
data: ArrayProtocol
n-dimensional positional data, must have 3 or more dims.
dims: Sequence[str]
names for each dimension in ``data``. Dimensions not listed in ``display_dims`` are treated as slider
dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges``.
Examples::
("trial", "line", "time", "xy")
("keypoints", "time", "xyz")
dims in the array do not need to be in the order that you want to display them, the data slice is
transposed into the order given by ``display_dims``.
display_dims : tuple[str, str, str]
The 3 spatial dims **in display order**: ``(n_graphics, p, <value dim>)``, i.e. the number of lines
or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim
which holds the xy or xyz coordinate and must be of size 2 or 3.
slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional
See :class:`NDSlicer`. The transform for the ``p`` dim is also used to map ``display_window`` and
the ``datapoints_window_func`` window size from reference units to array indices.
display_window: int, float or None, default 100
Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its
current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the
current index.
max_display_datapoints: int | None, default 1_000
Maximum number of datapoints to render per graphic. The step size of the display window slice is set
from this using floor division. ``None`` renders every datapoint in the window, with no decimation.
Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and
uploaded, which is slow for a large window over a large array.
datapoints_window_func: tuple[Callable, str, int | float], optional
Window function applied along the ``p`` dim after the display window has been taken, as
``(func, apply_dims, window_size)`` where:
* *func* must accept an ``axis: int`` kwarg (ex: ``np.mean``, ``np.max``). It is given a sliding
window view of the data and is reduced along the window axis.
* *apply_dims* names the coordinates of the value dim to apply it to, one of ``"all", "x", "y",
"z", "xy", "xz", "yz", "xyz"``. Coordinates that are not named are passed through unchanged.
* *window_size* is in the reference units of the ``p`` dim. It is mapped to array indices, clamped to
a minimum of 3, and rounded up to an odd size.
Important note: if used, ``display_window`` is approximate and not exact due to padding from the
window size. The window function is skipped when ``display_window`` is ``0``, or when the display
window spans more than ``2 * max_display_datapoints`` array indices, which would be too expensive to
compute.
kwargs
passed to :class:`NDSlicer`, i.e. ``window_funcs``, ``window_order`` and ``spatial_func``.
See Also
--------
NDSlicer : Base class with full parameter documentation.
NDPositions : The ``NDGraphic`` that uses this slicer by default.
"""
# through the setters so they are validated the same way here as they are when set later
self.display_window = display_window
self.max_display_datapoints = max_display_datapoints
super().__init__(
data=data,
dims=dims,
display_dims=display_dims,
slider_maps=slider_maps,
**kwargs,
)
self._datapoints_window_func = datapoints_window_func
# other graphic features windowed per-datapoint (arrays or callables), keyed by feature name
self._other_features: dict[str, Any] = dict()
[docs]
def set_other_feature(self, name: str, value):
"""set, or clear if ``value`` is None, an other graphic feature to window per-datapoint"""
if value is None:
self._other_features.pop(name, None)
elif callable(value):
self._other_features[name] = value
else:
self._other_features[name] = np.asarray(value)
@property
def display_dims(self) -> tuple[str, str, str]:
"""get or set the spatial dims, **in display order**"""
return self._display_dims
@display_dims.setter
def display_dims(self, sdims: tuple[str, str, str]):
if len(sdims) != 3:
raise IndexError
if not all([d in self.dims for d in sdims]):
raise KeyError
self._display_dims = tuple(sdims)
@property
def slider_dims(self) -> tuple[str, ...]:
"""slider dim names, the non-spatial dims plus the ``p`` dim"""
# append `p` dim to slider dims
return tuple([*super().slider_dims, self.display_dims[1]])
@property
def display_window(self) -> int | float | None:
"""get or set the display window, in the reference units of the ``p`` dim"""
return self._display_window
@display_window.setter
def display_window(self, dw: int | float | None):
if dw is None:
self._display_window = None
return
if not (
np.issubdtype(type(dw), np.integer) or np.issubdtype(type(dw), np.floating)
):
raise TypeError(
f"`display_window` must be an int, float, or `None`, you passed a "
f"{type(dw).__name__}: {dw}"
)
self._display_window = dw
@property
def max_display_datapoints(self) -> int | None:
"""
Get or set the maximum number of datapoints to render per graphic. The step size of the display window
slice is set from this using floor division.
``None`` renders every datapoint in the window, with no decimation. Neither ``None`` nor a very
large value is recommended: the entire window is then read into RAM and uploaded, which is slow
for a large window over a large array.
"""
return self._max_display_datapoints
@max_display_datapoints.setter
def max_display_datapoints(self, n: int | None):
if n is None:
self._max_display_datapoints = None
return
if not np.issubdtype(type(n), np.integer):
raise TypeError(
f"`max_display_datapoints` must be an integer or `None`, you passed a "
f"{type(n).__name__}: {n}"
)
if n < 2:
raise ValueError(f"`max_display_datapoints` must be >= 2, you passed: {n}")
self._max_display_datapoints = n
def _get_display_slice_step(self, n_datapoints: int) -> int:
"""step that keeps a slice of ``n_datapoints`` within ``max_display_datapoints``"""
if self.max_display_datapoints is None:
return 1
return max(1, n_datapoints // self.max_display_datapoints)
# TODO: validation for datapoints_window_func and size
@property
def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None:
"""
Get or set the window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``.
``apply_dims`` names the coordinates of the value dim that the window function is applied to, one of
``"all", "x", "y", "z", "xy", "xz", "yz", "xyz"``. ``window_size`` is in the reference units of the
``p`` dim.
"""
return self._datapoints_window_func
@datapoints_window_func.setter
def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]):
if len(funcs) != 3:
raise TypeError
self._datapoints_window_func = tuple(funcs)
def _get_dw_slice(self, indices: dict[str, Any]) -> slice:
# given indices, return slice required to obtain display window
# n_datapoints dim name
# display_window acts on this dim
p_dim = self.display_dims[1]
if self.display_window is None:
# just return everything
return slice(0, self.shape[p_dim])
if self.display_window == 0:
# just map p dimension at this index and return
index = self._ref_index_to_array_index(p_dim, indices[p_dim])
return slice(index, index + 1)
# half window size, in reference units
hw = self.display_window / 2
if self.datapoints_window_func is not None:
# add half datapoints_window_func size here, assumes the reference space is somewhat continuous
# and the display_window and datapoints window size map to their actual size values
hw += self.datapoints_window_func[2] / 2
# display window is in reference units, apply display window and then map to array indices
# start in reference units
start_ref = indices[p_dim] - hw
# stop in reference units
stop_ref = indices[p_dim] + hw
# map to array indices, stop is exclusive so it goes up to the size of the dim and is kept
# above start so the window always has at least one datapoint
start = self._ref_index_to_array_index(p_dim, start_ref)
stop = max(min(self.slider_maps[p_dim](stop_ref), self.shape[p_dim]), start + 1)
w = stop - start
# get step size
step = self._get_display_slice_step(w)
return slice(start, stop, step)
def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol:
"""
Takes array where display window has already been applied and applies window functions on the `p` dim.
Parameters
----------
array: ArrayProtocol
array of shape: [l, display_window, 2 | 3]
Returns
-------
ArrayProtocol
array with window functions applied along `p` dim
"""
if self.display_window == 0:
# can't apply window func when there is only 1 datapoint
return array
p_dim = self.display_dims[1]
# display window in array index space
if self.display_window is not None:
dw = self.slider_maps[p_dim](self.display_window)
# step size based on max number of datapoints to render
step = self._get_display_slice_step(dw)
# apply window function on the `p` n_datapoints dim
if (
self.datapoints_window_func is not None
# if there are too many points to efficiently compute the window func, skip
# applying a window func also requires making a copy so that's a further performance hit
# `max_display_datapoints = None` caps nothing, so there is no threshold to exceed
and (
self.max_display_datapoints is None
or dw < self.max_display_datapoints * 2
)
):
# get windows
# graphic_data will be of shape: [n, p, 2 | 3]
# where:
# n - number of lines, scatters, heatmap rows
# p - number of datapoints/samples
# ws is in ref units
wf, apply_dims, ws = self.datapoints_window_func
# map ws in ref units to array index
# min window size is 3
ws = max(self._ref_index_to_array_index(p_dim, ws), 3)
if ws % 2 == 0:
# odd size windows are easier to handle
ws += 1
hw = ws // 2
start, stop = hw, array.shape[1] - hw
# apply user's window func
# result will be of shape [n, p, 2 | 3]
if apply_dims == "all":
# windows will be of shape [n, p, 1 | 2 | 3, ws]
windows = sliding_window_view(array, ws, axis=-2)
return wf(windows, axis=-1)[:, ::step]
# map user dims str to tuple of numerical dims
coor_dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims))
# windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws]
windows = sliding_window_view(
array[..., coor_dims], ws, axis=-2
).squeeze()
# make a copy because we need to modify it
array = array[:, start:stop].copy()
# this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary
array[..., coor_dims] = wf(windows, axis=-1).reshape(
*array.shape[:-1], len(coor_dims)
)
return array[:, ::step]
step = self._get_display_slice_step(array.shape[1])
return array[:, ::step]
def _apply_spatial_func(self, array: ArrayProtocol) -> ArrayProtocol:
if self.spatial_func is not None:
return self.spatial_func(array)
return array
def _finalize(self, array: ArrayProtocol) -> ArrayProtocol:
return self._apply_spatial_func(self._apply_dw_window_func(array))
def _get_other_features(
self, data_slice: ArrayProtocol, dw_slice: slice
) -> dict[str, ArrayProtocol]:
# window the per-graphic datapoint (`p`) axis (axis 1) of each feature
other = dict()
for name, val in self._other_features.items():
if callable(val):
other[name] = val(data_slice, dw_slice)
else:
other[name] = val[:, dw_slice]
return other
[docs]
async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]:
"""
slices through all slider dims and outputs an array that can be used to set graphic data
Note that we do not use __getitem__ here since the index is a tuple specifying a single integer
index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here.
"""
# squeezed, dims in array order
window_output = await self.get_window_output(indices)
# transpose into display order, [n_graphics, p, xy(z)], which is what the display window
# slice below and everything in _finalize() index against
window_output = transpose(window_output, self.display_dims_indices)
# get slice obj for display window
dw_slice = self._get_dw_slice(indices)
# data that will be used for the graphical representation
# slice the datapoints to be displayed in the graphic using the display window slice
# `p` dim is dim 1 after the transpose above
graphic_data = window_output[:, dw_slice]
# _finalize runs the user's datapoints_window_func and spatial_func.
if isinstance(graphic_data, CudaArrayProtocol):
# the datapoints_window_func and spatial_func should be direct on-cuda functions
# ex: torch functions that can take cuda arrays directly
data = self._finalize(graphic_data)
else:
# run CPU functions, probably numpy-based, in a thread pool
data = await run_in_thread_pool(
self._executor, self._finalize, graphic_data
)
other = self._get_other_features(data, dw_slice)
# final CUDA -> numpy conversion at the end of the pipeline
if isinstance(data, CudaArrayProtocol):
data = await run_in_thread_pool(self._executor, cuda_to_numpy, data)
return {
"data": data,
**other,
}
[docs]
class NDPositions(NDGraphic):
def __init__(
self,
ref_index: ReferenceIndices,
nd_subplot: NDWSubplot,
data: Any,
dims: Sequence[str],
display_dims: tuple[str, str, str],
*args,
graphic_type: Type[
LineCollection | LineStack | ScatterCollection | ScatterStack
],
slicer: type[NDPositionsSlicer] = NDPositionsSlicer,
display_window: int | float | None = 10,
window_funcs: dict[
str, tuple[WindowFuncCallable | None, int | float | None]
] = None,
window_order: tuple[str, ...] = None,
spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None,
slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None,
max_display_datapoints: int | None = 1_000,
datapoints_window_func: tuple[Callable, str, int | float] | None = None,
colors: ColorsType = None,
cmap: str | Sequence[str] = None,
cmap_transform: np.ndarray | FeatureCallable = None,
cmap_range: tuple[float, float] = None,
thickness: float | Sequence[float] = None,
sizes: SizesType = None,
markers: MarkersType = None,
name: str = None,
graphic_kwargs: dict = None,
slicer_kwargs: dict = None,
):
"""
``NDGraphic`` subclass for n-dimensional positional data.
Uses an :class:`NDPositionsSlicer` to produce the data slices and manages one of four interchangeable
graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, and ``ScatterCollection``.
The representation can be changed at runtime by setting :attr:`graphic_type`.
Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must
have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The datapoints
dim, ``p``, is both a spatial dim and a slider dim, it is windowed by ``display_window`` and
``datapoints_window_func`` rather than by ``window_funcs``.
Parameters
----------
ref_index : ReferenceIndices
The shared reference index that delivers slider updates to this graphic.
nd_subplot : NDWSubplot
parent NDWSubplot the NDGraphic is in
data : array-like or None
n-dimensional positional data.
Ex: an array of shape ``[n_trials, n_lines, n_timepoints, 2]`` with ``dims`` of
``("trial", "line", "time", "xy")`` and ``display_dims`` of ``("line", "time", "xy")``.
Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using
:attr:`data`.
dims : Sequence[str]
Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``.
display_dims : tuple[str, str, str]
The 3 spatial dims **in display order**: ``(n_graphics, p, <value dim>)``, i.e. the number of lines
or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim
which holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this
order in the array, the data slice is transposed into display order.
args
extra positional arguments passed to the ``slicer`` constructor.
graphic_type : type[LineCollection | LineStack | ScatterCollection | ScatterStack]
The graphical representation used to display the data slice.
slicer : type[NDPositionsSlicer], default ``NDPositionsSlicer``
``NDPositionsSlicer`` subclass that manages the data and produces the data slices.
display_window : int, float or None, default 10
Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its
current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the
current index. This is what makes out-of-core rendering possible, i.e. rendering a window of a
dataset that is larger than GPU VRAM.
window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional
Per-slider-dim window functions applied around the current slider position, see
:class:`NDSlicer`. Not used for the ``p`` dim, see ``datapoints_window_func``.
window_order : tuple[str, ...], optional
Order in which the window functions are applied across dims. Only dims listed here have their window
function applied, see :class:`NDSlicer`.
spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional
A function applied to the spatial slice *after* the window funcs, right before rendering. It is
given the slice as ``[n_graphics, p, xy(z)]``, i.e. the array as it is rendered, and must return
an array with those same dims.
slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional
Per-slider-dim mapping from reference-space values to local array indices, see
:class:`NDSlicer`.
max_display_datapoints : int | None, default 1_000
Maximum number of datapoints to render per graphic. The step size of the display window slice is set
from this using floor division. ``None`` renders every datapoint in the window, with no decimation.
Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and
uploaded, which is slow for a large window over a large array.
datapoints_window_func : tuple[Callable, str, int | float], optional
Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see
:class:`NDPositionsSlicer`.
colors : str | Sequence[str] | np.ndarray | FeatureCallable, optional
Colors of the graphics. Mutually exclusive with ``cmap``, setting one clears the other.
* static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats
* static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA
* windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA
* windowed, a ``FeatureCallable``
cmap : str | Sequence[str], optional
Colormap applied to the graphics, always static. A single name for every graphic, or an iterable of
``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``.
cmap_transform : np.ndarray | FeatureCallable, optional
Values that the colormap colors are mapped from.
* static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color
* windowed, one value per datapoint, ``[n_graphics, p]``
* windowed, a ``FeatureCallable``
cmap_range : (float, float) | np.ndarray, optional
The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per
graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim,
so the display window keeps its position within the colormap. A ``FeatureCallable`` transform
requires an explicit range, its full range is not knowable without evaluating it everywhere.
thickness : float | Sequence[float], optional
Thickness of the lines, always static. A single value for every graphic, or ``[n_graphics]`` values
for a thickness per graphic.
sizes : float | Sequence[float] | np.ndarray | FeatureCallable, optional
Size of the scatter points.
* static, a single size for every graphic, or ``[n_graphics]`` sizes for one size per graphic
* windowed, one size per datapoint, ``[n_graphics, p]``
* windowed, a ``FeatureCallable``
markers : str | Sequence[str] | np.ndarray | FeatureCallable, optional
Marker shape of the scatter points.
* static, a single marker for every graphic, or ``[n_graphics]`` markers for one per graphic
* windowed, one marker per datapoint, ``[n_graphics, p]``
* windowed, a ``FeatureCallable``
name : str, optional
Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``.
graphic_kwargs : dict, optional
passed to the ``graphic_type`` constructor.
slicer_kwargs : dict, optional
passed to the ``slicer`` constructor.
Notes
-----
Each of the other graphic features is either *windowed* or *static*, decided from the value itself:
* **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with
the same display window slice as the data on every update, so the feature carries a value per
displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e.
``[n_graphics, p, <value dim>]``, since it is indexed with an index into the full ``p`` dim. A
``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature
values for the displayed datapoints.
* **static**: anything else. It is set once on the collection, ex: a single value for every graphic,
``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as
``itertools.cycle(["jet", "viridis"])``.
A feature the graphic type does not have is ignored, ex: ``thickness`` for scatters, ``markers`` for
lines.
See Also
--------
NDPositionsSlicer : The slicer that produces the data slices for this graphic.
"""
super().__init__(nd_subplot, name)
self.init(
ref_index,
data,
dims,
display_dims,
*args,
graphic_type=graphic_type,
slicer=slicer,
display_window=display_window,
window_funcs=window_funcs,
window_order=window_order,
spatial_func=spatial_func,
slider_maps=slider_maps,
max_display_datapoints=max_display_datapoints,
datapoints_window_func=datapoints_window_func,
colors=colors,
cmap=cmap,
cmap_transform=cmap_transform,
cmap_range=cmap_range,
thickness=thickness,
sizes=sizes,
markers=markers,
graphic_kwargs=graphic_kwargs,
slicer_kwargs=slicer_kwargs,
)
run_sync(self._create_graphic())
[docs]
def init(
self,
ref_index: ReferenceIndices,
data: Any,
dims: Sequence[str],
display_dims: tuple[str, str, str],
*args,
graphic_type: Type[
LineCollection | LineStack | ScatterCollection | ScatterStack
],
slicer: type[NDPositionsSlicer] = NDPositionsSlicer,
display_window: int | float | None = 10,
window_funcs: dict[
str, tuple[WindowFuncCallable | None, int | float | None]
] = None,
window_order: tuple[str, ...] = None,
spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None,
slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None,
max_display_datapoints: int | None = 1_000,
datapoints_window_func: tuple[Callable, str, int | float] | None = None,
colors: ColorsType = None,
cmap: str | Sequence[str] = None,
cmap_transform: np.ndarray | FeatureCallable = None,
cmap_range: tuple[float, float] = None,
thickness: float | Sequence[float] = None,
sizes: SizesType = None,
markers: MarkersType = None,
graphic_kwargs: dict = None,
slicer_kwargs: dict = None,
):
"""
Set up the slicer and per-graphic state, i.e. everything except creating the graphic.
Separated from ``__init__`` so ``NDTimeseries`` can run its own one-time setup
between this and graphic creation.
"""
self._ref_index = ref_index
if slicer_kwargs is None:
slicer_kwargs = dict()
if graphic_kwargs is None:
self._graphic_kwargs = dict()
else:
self._graphic_kwargs = graphic_kwargs
self._slicer = slicer(
data,
dims,
display_dims,
*args,
display_window=display_window,
max_display_datapoints=max_display_datapoints,
datapoints_window_func=datapoints_window_func,
window_funcs=window_funcs,
window_order=window_order,
spatial_func=spatial_func,
slider_maps=slider_maps,
**slicer_kwargs,
)
self._graphic_type = graphic_type
# each feature is either windowed per-datapoint (into the slicer) or static (onto
# the collection); _set_feature routes and stores it for re-creation on a type switch
self._static_features: dict[str, Any] = dict()
features = {
"colors": colors,
"cmap": cmap,
"cmap_transform": cmap_transform,
"cmap_range": cmap_range,
"thickness": thickness,
"sizes": sizes,
"markers": markers,
}
for name, value in features.items():
self._set_feature(name, value)
def _set_feature(self, name: str, value):
"""
Route a graphic feature to the collection.
A callable, or an array with the datapoint dim (``p``) at axis 1, is windowed
per-datapoint by the slicer and set onto the collection each frame. Anything else is
static: it is stored and set once onto the collection.
"""
if value is not None:
# explicit colors and a colormap are mutually exclusive; drop the other source
self._clear_conflicting_color_source(name)
if self._is_windowed(value):
self._static_features.pop(name, None)
self.slicer.set_other_feature(name, value)
if self._graphic is not None:
run_sync(self._set_indices_())
return
# static: clear any windowed version, store, and set it onto the collection
self.slicer.set_other_feature(name, None)
if value is None:
self._static_features.pop(name, None)
return
self._static_features[name] = value
if self._graphic is not None:
setattr(self.graphic, name, value)
def _get_feature(self, name: str):
# the static value, or the windowed value held by the slicer
if name in self._static_features:
return self._static_features[name]
return self.slicer._other_features.get(name)
def _clear_conflicting_color_source(self, name: str):
# a graphic's color is either explicit `colors` or a colormap, never both
if name == "colors":
conflicting = ("cmap", "cmap_transform", "cmap_range")
elif name in ("cmap", "cmap_transform", "cmap_range"):
conflicting = ("colors",)
else:
return
for other in conflicting:
self._static_features.pop(other, None)
self.slicer.set_other_feature(other, None)
def _is_windowed(self, value) -> bool:
# windowed features are per-datapoint and sliced to the display window each frame: a
# callable, or a ``[n_graphics, p, ...]`` array-like carrying the datapoint (`p`) axis.
# Anything else (a single value, or a per-graphic sequence/iterator) is static
if callable(value):
return True
if isinstance(value, (list, tuple, np.ndarray)):
value = np.asarray(value)
p_size = self.slicer.shape[self.slicer.display_dims[1]]
return value.ndim >= 2 and value.shape[1] == p_size
return False
def _cmap_range(self):
# the cmap_range over the full `p` dimension (per-graphic min/max of the stored
# cmap_transform), or the user's explicit cmap_range. A callable transform's full range
# isn't knowable without evaluating it everywhere, so that needs an explicit cmap_range
if "cmap_range" in self._static_features:
return self._static_features["cmap_range"]
transform = self.slicer._other_features.get("cmap_transform")
if not isinstance(transform, np.ndarray):
return None
if transform.ndim == 1:
return (float(transform.min()), float(transform.max()))
return np.stack([transform.min(axis=1), transform.max(axis=1)], axis=1)
@property
def slicer(self) -> NDPositionsSlicer:
"""NDSlicer that manages the data and produces data slices to display"""
return self._slicer
@property
def graphic(
self,
) -> LineCollection | LineStack | ScatterCollection | ScatterStack | None:
"""Underlying Graphic object used to display the current data slice, ``None`` if the data is ``None``"""
return self._graphic
@property
def graphic_type(
self,
) -> Type[LineCollection | LineStack | ScatterCollection | ScatterStack]:
"""
Get or set the graphical representation used to display the data slice. Setting it deletes the current
graphic and creates one of the given type using the current slice.
"""
return self._graphic_type
@graphic_type.setter
def graphic_type(self, graphic_type):
if type(self.graphic) is graphic_type:
return
self._nd_subplot.subplot.delete_graphic(self._graphic)
self._graphic_type = graphic_type
run_sync(self._create_graphic())
@property
def display_dims(self) -> tuple[str, str, str]:
"""
Get or set the spatial dims **in display order**: ``(n_graphics, p, <value dim>)``. Setting them
re-renders the current data slice.
"""
return self.slicer.display_dims
@display_dims.setter
def display_dims(self, dims: tuple[str, str, str]):
self.slicer.display_dims = dims
# force re-render
run_sync(self._set_indices_())
@property
def indices(self) -> dict[Hashable, Any]:
"""the current index of each slider dim in reference-space units, from the ``ReferenceIndex``"""
return {d: self._ref_index[d] for d in self.slicer.slider_dims}
async def _get_data_slice(self, indices: dict[str, Any]) -> dict[str, Any]:
return await self.slicer.get(indices)
async def _set_indices_(self, indices: dict[str, Any] = None):
if self.data is None:
return
if indices is None:
# fetch the latest indices from the ReferenceIndex
# else use passed indices from schedule time
indices = self.indices
new_features = await self._get_data_slice(indices)
self._update_graphic(new_features, indices)
self._last_indices = indices
def _set_other_features(self, new_features: dict[str, Any]):
# set each windowed feature across the collection via its property setter (cmap-family
# have no accessor); the setter broadcasts a shared value, switches each graphic's mode,
# and resizes to the current display window
for name, value in new_features.items():
if name == "data" or not hasattr(type(self.graphic), name):
# skip a feature the current graphic type doesn't have, e.g. sizes on lines
continue
setattr(self.graphic, name, value)
# a windowed cmap_transform makes the graphic auto-set cmap_range to just the displayed
# datapoints; override it with the range over the full `p` dimension so the display
# window maps to its position in the colormap
if "cmap_transform" in new_features and hasattr(
type(self.graphic), "cmap_range"
):
cmap_range = self._cmap_range()
if cmap_range is not None:
self.graphic.cmap_range = cmap_range
def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]):
data_slice = new_features["data"] # [n_graphics, n_datapoints, xy(z)]
if self.graphic.data[0].shape[0] != data_slice.shape[1]:
# n_datapoints changed, create new buffer
self.graphic.data[:] = data_slice
else:
# same num datapoints
self.graphic.data[:, :, : data_slice.shape[-1]] = data_slice
self._set_other_features(new_features)
def _tooltip_handler(self, graphic, pick_info):
# get graphic within the collection
n_index = np.argwhere(self.graphic.graphics == graphic).item()
p_index = pick_info["vertex_index"]
return self.slicer.tooltip_format(n_index, p_index)
async def _create_graphic(self):
if self.data is None:
return
new_features = await self._get_data_slice(self.indices)
self._setup_graphic(new_features, self.indices)
def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]):
"""Build and add the graphic for the current slice."""
data_slice = new_features["data"] # [n_graphics, n_datapoints, xy(z)]
self._graphic = self._graphic_type(
data_slice,
**get_supported_kwargs(
self._graphic_type, **self._static_features, **self._graphic_kwargs
),
)
self._set_other_features(new_features)
if self.slicer.tooltip:
for g in self._graphic.graphics:
g.tooltip_format = partial(self._tooltip_handler, g)
self._nd_subplot.subplot.add_graphic(self._graphic)
self._set_graphic_right_click()
@property
def display_window(self) -> int | float | None:
"""
Get or set the display window, in the reference units of the ``p`` dim. Setting it re-renders the
current data slice.
"""
return self.slicer.display_window
@display_window.setter
def display_window(self, dw: int | float | None):
self.slicer.display_window = dw
# force re-render
run_sync(self._set_indices_())
@property
def display_range(self) -> tuple[float, float] | None:
"""
The current range of the display window, ``[min, max]``, in reference units of the ``p`` dim.
The window is centered on the current ``p`` index, so this moves with the sliders. It is
``None`` when :attr:`display_window` is, since every datapoint is then displayed.
"""
if self.display_window is None:
return None
p_dim = self.slicer.display_dims[1]
center = self.indices[p_dim]
half_window = self.display_window / 2
return center - half_window, center + half_window
@property
def max_display_datapoints(self) -> int | None:
"""
Get or set the maximum number of datapoints rendered per graphic. Setting it re-renders the
current data slice.
``None`` renders every datapoint in the window, with no decimation. Neither ``None`` nor a very
large value is recommended: the entire window is then read into RAM and uploaded, which is slow
for a large window over a large array.
"""
return self.slicer.max_display_datapoints
@max_display_datapoints.setter
def max_display_datapoints(self, n: int | None):
self.slicer.max_display_datapoints = n
# force re-render
run_sync(self._set_indices_())
@property
def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None:
"""
Get or set the window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``.
``apply_dims`` names the coordinates of the value dim that the window function is applied to, one of
``"all", "x", "y", "z", "xy", "xz", "yz", "xyz"``. ``window_size`` is in the reference units of the
``p`` dim.
"""
return self.slicer.datapoints_window_func
@datapoints_window_func.setter
def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]):
self.slicer.datapoints_window_func = funcs
@property
def colors(self):
"""get or set the colors of the graphics"""
return self._get_feature("colors")
@colors.setter
def colors(self, value):
self._set_feature("colors", value)
@property
def cmap(self):
"""get or set the cmap of the graphics"""
return self._get_feature("cmap")
@cmap.setter
def cmap(self, value):
self._set_feature("cmap", value)
@property
def cmap_transform(self):
"""get or set the cmap_transform of the graphics"""
return self._get_feature("cmap_transform")
@cmap_transform.setter
def cmap_transform(self, value):
self._set_feature("cmap_transform", value)
@property
def cmap_range(self):
"""get or set the cmap_range of the graphics"""
return self._get_feature("cmap_range")
@cmap_range.setter
def cmap_range(self, value):
self._set_feature("cmap_range", value)
@property
def thickness(self):
"""get or set the thickness of the graphics"""
return self._get_feature("thickness")
@thickness.setter
def thickness(self, value):
self._set_feature("thickness", value)
@property
def sizes(self):
"""get or set the sizes of the graphics"""
return self._get_feature("sizes")
@sizes.setter
def sizes(self, value):
self._set_feature("sizes", value)
@property
def markers(self):
"""get or set the markers of the graphics"""
return self._get_feature("markers")
@markers.setter
def markers(self, value):
self._set_feature("markers", value)