"""Axis-specific cutoff and transform controls for the Gate Editor.
:func:`axis_at` identifies the axis associated with a pointer position from
its bounding box. :class:`AxisCutoffs` stores display limits by measurement,
and :func:`axis_menu_items` represents the corresponding menu as data so it
can be tested without opening a graphical popup.
Cutoffs affect only the displayed range; they do not filter rows or change
gate membership. Limits are keyed by measurement rather than by the current
X/Y assignment, so they follow a measurement when axes are exchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, Iterator, Optional, Sequence, Tuple
from .gate_settings import AXIS_SCALES
__all__ = [
"AXIS_NAMES", "AxisCutoff", "AxisCutoffs", "AxisMenuItem",
"axis_at", "axis_menu_items", "apply_cutoffs", "parse_cutoff",
]
#: What each axis is called in a menu title.
AXIS_NAMES: Dict[str, str] = {"x": "X axis", "y": "Y axis"}
#: Scales that draw nothing at all where the measurement reaches zero or
#: below, so the menu greys them there instead of accepting a click that
#: cannot take effect.
POSITIVE_ONLY: Tuple[str, ...] = ("log", "logit")
[docs]
class CutoffError(ValueError):
"""A cutoff that cannot be applied, with the reason in its message."""
@dataclass(frozen=True)
[docs]
class AxisCutoff:
"""The lowest and highest value an axis shows. ``None`` means "the data".
Either end may be left open: cutting the bottom off a long tail while
letting the top follow the data is the common case, and forcing both ends
would make the user invent a number for the end they did not care about.
:raises CutoffError: when the low end is not below the high end. Equal
ends give an axis with no extent, which matplotlib draws as a blank
panel rather than as an error.
"""
low: Optional[float] = None
high: Optional[float] = None
[docs]
def __post_init__(self) -> None:
"""Coerce the bounds to floats and reject an axis with no extent.
:raises CutoffError: if ``low`` is not below ``high`` -- an axis whose
ends meet draws as a blank panel rather than as an error.
"""
for name in ("low", "high"):
value = getattr(self, name)
if value is None:
continue
object.__setattr__(self, name, float(value))
if (self.low is not None and self.high is not None
and not self.low < self.high):
raise CutoffError(
f"the low cutoff ({self.low:g}) must be below the high one "
f"({self.high:g}); an axis whose ends meet has no extent and "
f"draws as a blank panel")
@property
[docs]
def is_set(self) -> bool:
"""Whether either end has been pinned."""
return self.low is not None or self.high is not None
[docs]
def limits(self, low: float, high: float) -> Tuple[float, float]:
"""``(low, high)`` with the unpinned ends filled from the data.
:param low: the data's lower end, used when :attr:`low` is not pinned.
:param high: the data's upper end, used when :attr:`high` is not
pinned.
"""
return (low if self.low is None else self.low,
high if self.high is None else self.high)
[docs]
def describe(self) -> str:
"""The cutoff as a user reads it: ``10 – 500``, ``≥ 10``, ``≤ 500``."""
if self.low is not None and self.high is not None:
return f"{self.low:g} – {self.high:g}"
if self.low is not None:
return f"≥ {self.low:g}"
if self.high is not None:
return f"≤ {self.high:g}"
return "none"
[docs]
class AxisCutoffs:
"""The cutoffs a session has set, keyed by measurement.
Empty cutoffs are removed rather than stored as ``(None, None)``. A
measurement is therefore present only when at least one limit is active.
:param initial: cutoffs to start from, keyed by measurement. Copied, so
the caller's dict is not mutated as the session sets more.
"""
def __init__(self, initial: Optional[Dict[str, AxisCutoff]] = None):
"""Create the cutoff set, optionally seeded.
:param initial: cutoffs by column; copied, so the caller's mapping is
not adopted.
"""
self._by_column: Dict[str, AxisCutoff] = dict(initial or {})
[docs]
def __len__(self) -> int:
"""Return how many columns carry a cutoff."""
return len(self._by_column)
[docs]
def __contains__(self, column: object) -> bool:
"""Report whether a column carries a cutoff.
:param column: the column name; coerced with :func:`str`.
:returns: ``True`` if it has one.
"""
return str(column) in self._by_column
[docs]
def __iter__(self) -> Iterator[str]:
"""Iterate the column names that carry a cutoff."""
return iter(self._by_column)
[docs]
def columns(self) -> Tuple[str, ...]:
"""Every measurement that carries a cutoff, in the order they were set."""
return tuple(self._by_column)
[docs]
def get(self, column: Optional[str]) -> AxisCutoff:
"""The cutoff for ``column``, or an empty one. Never ``None``.
Callers ask this on every render, so returning an empty cutoff rather
than ``None`` keeps the ``if cutoff is None`` branch out of the
drawing path.
:param column: the measurement column, or None; None or empty gives an
empty cutoff.
"""
if not column:
return AxisCutoff()
return self._by_column.get(str(column), AxisCutoff())
[docs]
def set(self, column: str, low: Optional[float] = None,
high: Optional[float] = None) -> AxisCutoff:
"""Pin ``column`` between ``low`` and ``high``. Returns what was stored.
Setting both ends to ``None`` clears the column rather than storing an
empty cutoff, so a cleared measurement stops reporting as cut off.
:param column: the measurement column, converted to a string.
"""
cutoff = AxisCutoff(low, high)
name = str(column)
if not cutoff.is_set:
self._by_column.pop(name, None)
return cutoff
self._by_column[name] = cutoff
return cutoff
[docs]
def clear(self, column: str) -> bool:
"""Forget ``column``'s cutoff. Returns whether there was one.
:param column: the measurement column, converted to a string.
"""
return self._by_column.pop(str(column), None) is not None
[docs]
def clear_all(self) -> int:
"""Forget every cutoff. Returns how many were dropped."""
count = len(self._by_column)
self._by_column.clear()
return count
[docs]
def parse_cutoff(text: str) -> Optional[float]:
"""A number typed into a cutoff box, or ``None`` for "leave this end".
Blank means the data decides that end. A blank box is the only way to say
"cut the bottom off and let the top follow the data", so it is a value
rather than an error.
:param text: what was typed in the cutoff box; stripped, and blank means
None.
:raises CutoffError: for text that is neither blank nor a number, naming
what was typed -- a silent fall back to "the data decides" would look
exactly like the cutoff having been applied and done nothing.
"""
stripped = str(text).strip()
if not stripped:
return None
try:
return float(stripped)
except ValueError:
raise CutoffError(
f"{stripped!r} is not a number; leave the box empty to let the "
f"data decide that end") from None
[docs]
def axis_at(point: Sequence[float],
bbox: Sequence[float]) -> Optional[str]:
"""Which axis a click at ``point`` landed on: ``"x"``, ``"y"`` or ``None``.
:param point: ``(x, y)`` in the figure's display coordinates, which have
their origin at the BOTTOM left -- the convention every matplotlib
bounding box uses, so the two never need converting between.
:param bbox: the plotting rectangle as ``(x0, y0, x1, y1)``.
:returns: ``None`` inside the rectangle, where the plot's own menu
belongs, and for the margins that belong to neither axis.
The strip BELOW the rectangle is the x axis and the strip to its LEFT is
the y axis -- that is where the ticks and the axis label are drawn, so it
is where a user aiming at "the axis" clicks. In the corner where the two
strips overlap the further overshoot wins: well to the left and barely
below is the y axis, and the other way round is the x axis.
"""
x, y = float(point[0]), float(point[1])
x0, y0, x1, y1 = (float(value) for value in bbox)
if x0 <= x <= x1 and y < y0:
return "x"
if y0 <= y <= y1 and x < x0:
return "y"
if x < x0 and y < y0:
return "y" if (x0 - x) > (y0 - y) else "x"
return None
@dataclass(frozen=True)
[docs]
def apply_cutoffs(axes, columns: Sequence[Optional[str]],
cutoffs: AxisCutoffs) -> Tuple[str, ...]:
"""Narrow ``axes`` to the cutoffs set for the measurements it draws.
:param axes: a matplotlib ``Axes``.
:param columns: ``(x_column, y_column)`` -- what is on each axis now.
:param cutoffs: the session's cutoffs, keyed by measurement.
:returns: the axes that were narrowed, e.g. ``("x",)``.
The unpinned end of a one-sided cutoff is taken from the limits the data
already produced, so cutting the bottom off leaves the top where the
scatter put it rather than collapsing it onto the cut.
"""
narrowed = []
getters = {"x": (axes.get_xlim, axes.set_xlim),
"y": (axes.get_ylim, axes.set_ylim)}
for axis, column in zip(("x", "y"), tuple(columns) + (None, None)):
cutoff = cutoffs.get(column)
if not cutoff.is_set:
continue
get, set_ = getters[axis]
low, high = get()
set_(*cutoff.limits(float(low), float(high)))
narrowed.append(axis)
return tuple(narrowed)