Source code for spacr.qt.widgets.gate_editor

"""Drawing gates — the chart, the shapes on it, and the hierarchy beside it.

The chrome over :mod:`spacr.qt.widgets.gate_spec`. Everything about what a gate
*means* — the geometry, the chain, the percentages, the filter clause — is in
there; this module is three drawing tools and a tree.

:class:`GateCanvas` subclasses
:class:`spacr.qt.widgets.graph_builder.GraphCanvas` for the same reason
:class:`spacr.qt.widgets.trellis_view.TrellisCanvas` does: the marks, the hue
order, the density raster and the large-data policy are one implementation.
What it adds is a *mode*. With no tool armed the drag is the inherited brush;
with a tool armed the drag draws a shape and nothing is published until the
gate is named.

The three tools, and why the drag means different things
---------------------------------------------------------

* **Threshold** — a horizontal sweep on a one-column plot. Only the x extent is
  read, because the y axis of a histogram is a count and gating on a count is
  not a thing anyone means.
* **Rectangle** — a drag on a two-column plot, both extents read.
* **Polygon** — click a vertex at a time, then close it. Three vertices
  minimum, enforced where the gate is built rather than where it is applied.

The population is the parent's, always
---------------------------------------

A gate is drawn **inside** whatever is selected in the tree, and the canvas
shows that parent's population rather than the whole table — because a gate
drawn on a picture of everything and then applied to a subset is a gate nobody
placed. The header says which population is on screen and how many objects that
is.
"""
from __future__ import annotations

from dataclasses import dataclass, replace
import logging
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
import pandas as pd
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter
from PySide6.QtWidgets import (
    QButtonGroup, QLabel,
    QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
    QFormLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
    QPushButton,
    QSpinBox, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
    QWidget,
)

from ...selection import DataFilter
from ..theme import (SPACING, active_palette, make_transparent,
                     mark_surface, paint_panel)
from .graph_builder import GraphCanvas
from .graph_spec import BAR, HISTOGRAM, GraphSpec
from .gate_spec import (
    BOX, BoxGate, ELLIPSE, EllipseGate, Handle, WAND,
    GATE_KINDS, POLYGON, RECTANGLE, THRESHOLD, Gate, GateError, GateSet,
    PolygonGate, RectGate, ThresholdGate,
    CYLINDER, PRISM,
    COMPOSITE,
    CylinderGate, PrismGate, VIEW_LASSO, ViewGate, CompositeGate,
    _EllipsoidGate, points_in_polygon,
)
from .toggle import Toggle
from .volume_view import rotate_about_world, trackball, view_axes
from ..i18n import tr
from .sortable_table import install_sorting, tree_item

LOG = logging.getLogger("spacr.qt.gate_editor")


def _project(ax, point):
    """A data point's position in the 3D axes' own 2D coordinates.

    Wrapped because matplotlib has moved this: `proj3d.proj_transform` takes
    the projection matrix as `ax.M` in some versions and `ax.get_proj()` in
    others, and the import path has changed too. One place to be wrong.
    """
    from mpl_toolkits.mplot3d import proj3d

    matrix = getattr(ax, "M", None)
    if matrix is None:
        matrix = ax.get_proj()
    x, y, _z = proj3d.proj_transform(point[0], point[1], point[2], matrix)
    return (x, y)


def _set_view(ax, elevation: float, azimuth: float, roll: float = 0.0) -> None:
    """Point a 3D axes' camera, roll included where the axes takes one."""
    try:
        ax.view_init(elev=float(elevation), azim=float(azimuth),
                     roll=float(roll))
    except TypeError:
        ax.view_init(elev=float(elevation), azim=float(azimuth))


def _wrapped_angle(angle: float) -> float:
    """An angle in degrees folded into ``[-180, 180)``."""
    return (float(angle) + 180.0) % 360.0 - 180.0


def _is_right_button(event) -> bool:
    """Whether a mouse event came from the right button.

    A right-button drag always turns the volume, so the view can be adjusted
    in the middle of drawing without switching back to Spin.
    """
    try:
        return int(getattr(event, "button", 1) or 1) == 3
    except (TypeError, ValueError):
        return False


[docs] def fit_to_text(widget, *, padding: int = 16, lines: int = 1) -> None: """Size ``widget`` so its own text cannot be clipped. Measured with the widget's REAL font metrics, so it follows the theme, the platform and the user's DPI rather than a number that was right on one machine. Height is set as well as width: the reports are "cutt of on the sides usually its from the top asn sometimes botom", and a control sized only horizontally clips its ascenders exactly the way described. A minimum, never a fixed size -- a layout may still give the widget more, and a widget that cannot grow is the other half of this same bug. """ metrics = widget.fontMetrics() text = widget.text() if hasattr(widget, "text") else "" width = metrics.horizontalAdvance(str(text) or "MM") + padding height = metrics.height() * max(1, lines) + max(8, padding // 2) widget.setMinimumSize(max(width, widget.minimumWidth()), max(height, widget.minimumHeight()))
#: Colours gates are drawn in, cycled by position in the gate set. Chosen to #: stay apart from each other AND from a viridis-coloured cloud underneath -- #: a gate outline in the same green as the density it sits on is invisible #: exactly where it matters. Also distinguishable in the common forms of #: colour blindness, since the colour is the only thing telling two gates #: apart on the plot. GATE_COLOURS: Tuple[str, ...] = ( "#ff4d6d", "#4cc9f0", "#ffb703", "#b388ff", "#06d6a0", "#ff8fab", "#8ecae6", "#f4a261", ) #: Key the gate tree's stylesheet is registered under. QSS_NAME = "GateHierarchy" #: Where the graph | gate table split remembers the widths the user dragged #: it to (item 471): the graph and the gate table each fold by their heading #: and trade width by the handle between them. GRAPH_SPLIT_KEY = "gate_editor::graph" #: Prefix of the fold keys of the graph and the gate table sections. FOLD_KEY = "gate_editor" def _gate_tree_qss(palette, opacity=None) -> str: """Colours for the gate list. It had no block at all, so its rows fell back to Qt's default text colour -- black -- on the theme's surface. On the dark themes that is black on grey and simply cannot be read. Theme foreground, and no background of its own: painting a colour here would freeze one opacity into the list while everything around it kept following the preference. The tree is marked as a surface (see `GateTree`), so the theme's ``*[spacrSurface="true"]`` rule supplies the fill at the user's page opacity -- declaring ``background: transparent`` here would beat that rule and leave the list with no surface at all. """ return f""" QTreeWidget#GateHierarchy {{ color: {palette['fg']}; border: none; }} QTreeWidget#GateHierarchy::item {{ color: {palette['fg']}; padding: 2px 4px; }} QTreeWidget#GateHierarchy::item:selected {{ background: {palette['accent']}; color: {palette['bg']}; }} QTreeWidget#GateHierarchy QHeaderView::section {{ background: transparent; color: {palette['fg_muted']}; border: none; padding: 2px 4px; }} QTreeWidget#GateHierarchy QHeaderView::section:hover {{ background: {palette['accent']}; color: {palette['bg']}; }} QWidget#GateTree {{ background: transparent; }} """ try: from ..theme import register_widget_qss as _register_widget_qss _register_widget_qss(QSS_NAME, _gate_tree_qss, replace=True) except Exception: LOG.debug("could not register the gate tree stylesheet", exc_info=True) __all__ = ["GateCanvas", "GateTree", "GateEditorPanel", "TOOL_LABELS"] #: The tool a fresh editor starts on. RECTANGLE, not brush: "drag to draw a #: box" is what a user tries first, and starting on the brush meant a drag #: highlighted instead of drawing and the editor looked as though it could #: not make a gate at all. DEFAULT_TOOL = RECTANGLE #: What each tool is called, and what the gesture is. TOOL_LABELS = { "": "Brush (no gate) — drag to highlight, as everywhere else", THRESHOLD: "Threshold — drag across a histogram to cut one column", RECTANGLE: "Rectangle — drag a box on a two-column plot", ELLIPSE: "Oval — drag a box; the oval is drawn inside it", POLYGON: "Polygon — click each vertex, then Close. In 3D the vertices " "land on the anchor plane and the shape becomes a prism", WAND: "Wand — click a population; the gate grows to fit it", BOX: "Box — three measurements at once, made from the 3D view", CYLINDER: "Cylinder — an oval drawn on one plane of the 3D view, " "extended along the third measurement", PRISM: "Prism — a polygon drawn on one plane of the 3D view, " "extended along the third measurement", VIEW_LASSO: "Through the view — an outline drawn on the 3D view at any " "angle, extended straight through the volume", COMPOSITE: "Combined — other gates added to or subtracted from each " "other, chosen in the gates panel", } #: The shapes a drag on the anchor plane can draw, in the order the dropdown #: lists them. Each is drawn FLAT on the chosen plane and extended along that #: plane's own axis when the gate is made, so the gesture stays 2D and the #: gate is solid -- which is what "propagated through the graph" means. #: #: A DROPDOWN, because that is where a user looks for a shape. The first #: attempt at this hid the volume shapes from the tool picker on the grounds #: that they are "not drag tools"; that reasoning served the implementation, #: and left the control looking as though 3D gating had not been built. VOLUME_SHAPES: Tuple[Tuple[str, str], ...] = ( ("lasso", "Lasso through view"), ("view_polygon", "Polygon through view"), ("view_rect", "Rectangle through view"), ("ellipsoid", "Ellipsoid with handles"), ("box_handles", "Box with handles"), ("box", "Box gate"), ("oval", "Oval gate"), ("circle", "Circle gate"), ("polygon", "Polygon gate"), ) #: The volume shapes drawn on the screen and swept along the line of sight, #: at any angle, rather than on one of the three axis planes. VIEW_SHAPES = ("lasso", "view_rect") #: The volume shapes framed by a rectangle on the screen and then fitted, on #: all three measurements, to the objects that rectangle encloses. _FITTED_SHAPES = ("box_handles", "ellipsoid") #: Shapes whose drag is a screen rectangle rather than a free outline. _RECT_SHAPES = ("view_rect",) + _FITTED_SHAPES def _screen_points(ax, points) -> Optional[np.ndarray]: """Where 3D data points land on the canvas, in pixels. Projected with the camera as it is now (``get_proj``), not with the matrix of the last draw, so a point is placed correctly straight after a spin. Returns None when the axes cannot project. """ try: from mpl_toolkits.mplot3d import proj3d array = np.asarray(points, dtype=float).reshape(-1, 3) matrix = ax.get_proj() xs, ys, _zs = proj3d.proj_transform( array[:, 0], array[:, 1], array[:, 2], matrix) return np.asarray(ax.transData.transform( np.column_stack([xs, ys])), dtype=float) except Exception: LOG.debug("could not project points to the screen", exc_info=True) return None
[docs] class GateCanvas(GraphCanvas): """A :class:`GraphCanvas` you can draw gates on. Adds interactive gate drawing, dragging and hit-testing to the shared canvas, and pins the axes while a gate exists -- see :data:`RESCALE_ON_FILTER` for why that is not optional here. Emits :attr:`gate_drawn` with a finished :class:`~spacr.qt.widgets.gate_spec.Gate` that has **no name yet** -- naming is the host's job, because a gate is not a gate until it is named and a dialog does not belong in a canvas. :param parent: parent widget. :param link: the :class:`~spacr.qt.linked_selection.LinkedSelection` this view joins, so selecting here selects in every other view on it. ``None`` joins the shared one; pass a private one in a test so the selection does not reach the rest of the application. :param source: this view's name on that link, stamped onto everything it publishes -- which is how a view knows not to answer its own selection. """ #: Gating is the one place a filter must NOT move the axes. A gate is #: drawn in data coordinates on a particular view; rescaling to the rows #: it kept moves that view out from under it, which reads as the plot #: zooming into the gate and makes the gate impossible to drag. RESCALE_ON_FILTER = False #: A shape was completed. Carries a gate named ``"(unnamed)"``. gate_drawn = Signal(object) #: A gate was moved or resized in place. Carries the EDITED gate; the #: panel replaces the one of the same name. gate_edited = Signal(object) #: A polygon gained or lost a vertex — for a host showing the count. polygon_changed = Signal(int) #: A wand click could not become a gate. Carries the reason, which always #: names the setting or the gesture that would fix it. wand_failed = Signal(str) #: The volume is waiting for its second gesture, or cannot read it. #: Keeping this on the canvas lets the panel explain the state without #: making the geometry layer depend on a particular status widget. depth_requested = Signal(str) def __init__(self, parent=None, *, link=None, source: str = "gate_editor"): """Build a gating canvas over one graph spec. :param parent: parent widget. :param link: the shared selection to join, if any. :param source: the table being gated. """ super().__init__(parent, link=link, source=source) self.setAttribute(Qt.WA_TranslucentBackground, True) make_transparent(self) padding = SPACING["md"] self.layout().setContentsMargins(padding, padding, padding, padding) self._canvas._spacr_panel = False self._tool = DEFAULT_TOOL #: How near the first vertex a click has to land to close a polygon. #: Pixels, because "close enough to click" is a screen property. self.CLOSE_RADIUS_PX = 12.0 #: Set while a gate is being dragged. `None` whenever it is not, #: which is what every handler gates on. self._move_name: Optional[str] = None self._move_from: Optional[Tuple[float, float]] = None self._pending: List[Tuple[float, float]] = [] self._gates = GateSet() self._active: Optional[str] = None self._artists: List[object] = [] #: Gates the user has toggled OFF. Names, not gates, so a gate that #: is edited in place keeps its toggle. self._disabled: set = set() try: self._canvas.mpl_connect("scroll_event", self._on_scroll) self._canvas.mpl_connect("button_release_event", self._on_button_release) except Exception: LOG.debug("no scroll events available", exc_info=True) #: Which plane the pending polygon's vertices were clicked on, as #: (first, second). A polygon spanning two planes is not one shape. self._pending_plane: Optional[Tuple[str, str]] = None #: Set while an anchor point is being pulled: (gate name, role). self._resize: Optional[Tuple[str, str]] = None #: The dashed shape following the mouse mid-drag. Its artists are #: tracked separately from `_artists` so a motion event can replace #: it without redrawing every gate and every highlight -- which is a #: mask over the whole table per gate, per mouse move. self._ghost: List[object] = [] #: How near an anchor a press has to land to grab it. Pixels: "close #: enough to grab" is a property of the screen, not of the data. self.HANDLE_RADIUS_PX = 9.0 #: Set by `apply_settings`. Defaults match GateEditorSettings, so the #: canvas draws the same with or without a settings object. self._settings = None self._highlight_gated = True self._line_width = 0.5 self._colour_map = "viridis" self._resolution = "points" self._bins = 200 self._show_grid = False self._x_scale = "linear" self._y_scale = "linear" self._colour_by = "density" #: Limits set by the wheel, or None to follow the data. self._zoom = None #: "2D", "3D" or "xD" -- see `set_mode`. self._mode = "2D" self._z_column = "" #: How far the volume is zoomed in. 1.0 is the data's own extent. self._volume_zoom = 1.0 #: (elevation, azimuth, roll) once the user has turned it, else None. self._view_angles = None #: Which axis the volume spins about: "x", "y", "z", or "" for a free #: trackball. Free by default: locked to "z" a drag could only ever #: turn the volume about one axis. self._spin_axis = "" self._spin_from = None #: What the press that is being released started: "spin", "draw" or #: None. A snap belongs to the end of a spin, never to a drawing. self._last_gesture: Optional[str] = None #: Where a draw-in-the-volume drag started, or None. self._volume_drag = None #: The outline being drawn through the view, in canvas pixels, or #: None when no such drag is in flight. self._lasso: Optional[List[Tuple[float, float]]] = None #: A 3D shape is two gestures: first its footprint on the selected #: plane, then its depth along that plane's normal. The first gate is #: held here until the second gesture makes that statement complete. self._pending_volume_gate: Optional[Gate] = None self._pending_volume_axis: str = "" self._depth_drag_from: Optional[Tuple[float, float]] = None #: The polygon being clicked on the 3D view, in canvas pixels. self._view_polygon: List[Tuple[float, float]] = [] #: The handle being pulled in the volume: (gate name, role, start #: pixel, gate as it was), or None. self._handle_drag = None #: The edited gate shown while a handle is pulled, or None. self._volume_preview: Optional[Gate] = None #: The live highlight of the objects inside a shape being drawn. self._live = None
[docs] def paintEvent(self, event) -> None: """Keep the plotted 2D or 3D points over one solid rounded surface. :param event: the Qt paint event for this canvas. :returns: None. """ painter = QPainter(self) try: paint_panel(painter, self, role="elevated", inset=0.5) finally: painter.end()
@property
[docs] def tool(self) -> str: """Which drawing tool is armed. :returns: the tool's name. """ return self._tool
[docs] def set_tool(self, tool: str) -> None: """Arm a drawing tool, or ``""`` to go back to brushing. :param tool: a gate kind from :data:`~spacr.qt.widgets.gate_spec.GATE_KINDS` (e.g. ``"rectangle"``, ``"polygon"``), or ``""``; anything else raises :class:`GateError`. Any part-drawn gate is discarded. """ if tool and tool not in GATE_KINDS: raise GateError( f"unknown gate tool {tool!r}; the tools are " f"{', '.join(GATE_KINDS)}") self._tool = tool self.clear_pending()
[docs] def pending_vertices(self) -> Tuple[Tuple[float, float], ...]: """The polygon vertices clicked so far.""" return tuple(self._pending)
[docs] def clear_pending(self) -> None: """Throw away a part-drawn gate and tell the panel the count is zero. The signal matters as much as the clearing: the panel's Finish button is enabled by the vertex count, and clearing without saying so would leave it offering to close a polygon that no longer exists. """ self._pending = [] self._view_polygon = [] self.polygon_changed.emit(0) self.render_now()
@property
[docs] def gates(self) -> GateSet: """The gates drawn on this canvas. :returns: the gate set. """ return self._gates
[docs] def set_gates(self, gates: GateSet, *, active: Optional[str] = None) -> None: """Display ``gates`` and select ``active`` as the hierarchy parent. :param gates: the gate set to draw; it replaces the current one. """ self._gates = gates self._active = active self.render_now()
@property
[docs] def active_gate(self) -> Optional[str]: """Return the selected gate used as the next gate's parent.""" return self._active
[docs] def population(self) -> Optional[pd.DataFrame]: """Return the locally filtered table displayed beneath gate overlays. Selecting a gate does not subset this frame; the selected gate only determines the parent of the next gate drawn. """ if self._frame is None: return None base, _note = self._apply_filter(self._frame) return base
[docs] def apply_settings(self, settings) -> None: """Apply drawing settings and redraw the canvas once. Missing attributes retain their defaults so older saved settings and lightweight settings objects remain usable. :param settings: a :class:`~spacr.qt.widgets.gate_settings.GateEditorSettings` or any object with some of its attributes (``default_tool``, ``point_size``, ``colour_map``, ``log_x`` and so on), read with :func:`getattr`. """ self._settings = settings tool = getattr(settings, "default_tool", None) if tool and tool in GATE_KINDS and not self._tool: self._tool = tool self._highlight_gated = bool(getattr(settings, "highlight_gated", True)) self._line_width = float(getattr(settings, "gate_line_width", 0.5)) self.POINT_SIZE_BASE = float(getattr(settings, "point_size", 6.0)) ** 2 self.POINT_ALPHA = float(getattr(settings, "point_opacity", 0.6)) self._colour_map = str(getattr(settings, "colour_map", "viridis")) self._resolution = str(getattr(settings, "resolution_mode", "points")) self._bins = int(getattr(settings, "bins", 200)) self._show_grid = bool(getattr(settings, "show_grid", False)) scale_for = getattr(settings, "scale_for", None) if callable(scale_for): self._x_scale, self._y_scale = scale_for("x"), scale_for("y") else: self._x_scale = "log" if getattr(settings, "log_x", False) else "linear" self._y_scale = "log" if getattr(settings, "log_y", False) else "linear" self._colour_by = str(getattr(settings, "colour_by", "density")) self.render_now()
[docs] def point_colormap(self): """The colour map the user chose, by name. An unknown name falls back rather than raising: matplotlib's registry changes between versions, and a colour map that no longer exists must not take the whole plot with it (INVARIANTS 10). """ from matplotlib import colormaps try: return colormaps[self._colour_map] except (KeyError, TypeError): LOG.info("no colour map called %r; using the theme's", self._colour_map) return super().point_colormap()
[docs] def decorate_axes(self, ax) -> None: """Grid and log scales. Log is applied only where it is legal: a log axis over data that reaches zero or below draws nothing at all, which reads as the plot having broken rather than as the setting being inapplicable. :param ax: the Matplotlib axes to decorate in place. """ palette = active_palette() if self._show_grid: ax.grid(True, color=palette["fg_muted"], alpha=0.25, linewidth=0.5) else: ax.grid(False) ax.set_axisbelow(True) spec = self._spec for scale, column, setter in ( (self._x_scale, getattr(spec, "x", None), ax.set_xscale), (self._y_scale, getattr(spec, "y", None), ax.set_yscale)): if scale == "linear" or not column: continue if scale in ("log", "logit") and not self._column_is_positive(column): LOG.info("%s scale skipped: %s reaches zero or below", scale, column) continue try: setter(scale) except Exception: LOG.info("axis scale %r did not apply", scale, exc_info=True)
def _column_is_positive(self, column: str) -> bool: """Whether every finite value of ``column`` is above zero. A log axis over data that reaches zero draws nothing at all, which reads as the plot having broken rather than as the setting being inapplicable to this measurement. """ frame = self._frame if frame is None or column not in frame.columns: return False values = pd.to_numeric(frame[column], errors="coerce").to_numpy(float) values = values[np.isfinite(values)] return bool(len(values)) and float(values.min()) > 0 def _draw_plain_points(self, ax, x, y, rows, palette): """Colour the cloud by DENSITY when there is no colour column. "cmap dosnt seem to be allpied to the data , they are always blue." A cytometry scatter has no colour axis, so the base canvas drew one flat colour and the chosen map had nothing to colour. Density is what the map should show: on a crowded plot the overlap is the reading, and a single colour hides it entirely. The binned resolution modes replace the points outright; this is the `points` mode, which keeps one marker per object. """ if self._resolution != "points": finite = np.isfinite(x) & np.isfinite(y) return self._draw_binned(ax, x[finite], y[finite]) values = self._colour_values(x, y, rows) if values is None: return ax.scatter(x, y, s=self._sizes(rows), color=self._series_colour(0), linewidths=0.0, alpha=self.POINT_ALPHA) return ax.scatter(x, y, s=self._sizes(rows), c=values, cmap=self.point_colormap(), linewidths=0.0, alpha=self.POINT_ALPHA) def _colour_values(self, x, y, rows): """The per-point value the colour map is applied to, or None for flat. A named column wins over density, so "colour by pathogen count" means that and not an approximation of it. A column that is missing or non-numeric falls back to density rather than to an error -- the colour axis is decoration (INVARIANTS 10). """ choice = self._colour_by if choice == "flat": return None if choice and choice != "density" and choice in rows.columns: values = pd.to_numeric(rows[choice], errors="coerce").to_numpy(float) if np.isfinite(values).any(): return values LOG.info("column %r has no numeric values; colouring by density", choice) return self._density(x, y) def _draw_density(self, ax, rows, palette) -> None: """Large tables take a different path, and it has to obey the settings. Past its large-data threshold the base canvas rasterises with imshow and never calls `_draw_points` at all -- which is why "viridis does work but not when there are more than 50000 data points" and why hexbin appeared to do nothing: both live in the points path. The chosen resolution mode is honoured here too. `points` on a table this size still means the raster, because one marker per object is what the threshold exists to avoid. """ spec = self._spec if self._resolution == "points" or not (spec.x and spec.y): super()._draw_density(ax, rows, palette) return x = pd.to_numeric(rows[spec.x], errors="coerce").to_numpy(float) y = pd.to_numeric(rows[spec.y], errors="coerce").to_numpy(float) finite = np.isfinite(x) & np.isfinite(y) self._draw_binned(ax, x[finite], y[finite]) def _draw_binned(self, ax, x, y): """hexbin / histogram / density, one implementation for both paths.""" if len(x) == 0: return None if self._resolution == "hexbin": return ax.hexbin(x, y, gridsize=max(10, min(self._bins // 4, 200)), cmap=self.point_colormap(), mincnt=1, linewidths=0.0) counts, xe, ye = np.histogram2d( x, y, bins=max(10, min(self._bins, 1000))) counts = counts.T if self._resolution == "density" and counts.sum() > 0: counts = counts / counts.sum() masked = np.ma.masked_where(counts <= 0, counts) return ax.pcolormesh(xe, ye, masked, cmap=self.point_colormap(), shading="auto") def _density(self, x, y): """A per-point density, binned rather than kernel-estimated. A Gaussian KDE over a million objects is minutes; a 2D histogram lookup is milliseconds and produces the same reading at the resolution a screen can show. """ finite = np.isfinite(x) & np.isfinite(y) out = np.zeros(len(x)) if not finite.any(): return out bins = max(10, min(self._bins, 512)) counts, xe, ye = np.histogram2d(x[finite], y[finite], bins=bins) xi = np.clip(np.digitize(x[finite], xe) - 1, 0, counts.shape[0] - 1) yi = np.clip(np.digitize(y[finite], ye) - 1, 0, counts.shape[1] - 1) out[finite] = counts[xi, yi] return out
[docs] def is_gate_enabled(self, name: str) -> bool: """Whether ``name`` is drawn. Unknown gates are on: a gate that has never been toggled has never been turned off. :param name: the gate's name. """ return name not in self._disabled
[docs] def set_gate_enabled(self, name: str, on: bool) -> None: """Turn a gate's outline and highlight on or off. Off means NOT DRAWN, never deleted and never removed from the set: the gate keeps its shape, its parent and its children, and comes back exactly as it was. Its rows stay on the plot either way -- toggling changes what is marked, not what exists. :param name: the gate's name. :param on: ``True`` draws it, ``False`` hides it. """ if on: self._disabled.discard(name) else: self._disabled.add(name) self.render_now()
@property
[docs] def enabled_gates(self) -> Tuple[str, ...]: """The names currently drawn, in definition order.""" return tuple(g.name for g in self._gates.gates if self.is_gate_enabled(g.name))
[docs] def set_mode(self, mode: str, *, z_column: str = "") -> None: """Switch between the 2D scatter and the 3D volume. :param mode: ``"2D"``, ``"3D"`` or ``"xD"``; anything else is taken as ``"2D"``. """ self._mode = mode if mode in ("2D", "3D", "xD") else "2D" self._z_column = z_column or self._z_column self.render_now()
def _render_volume(self) -> bool: """Draw the 3D view. Returns False if it cannot, so 2D takes over. A real third axis rather than a projection trick: matplotlib's Axes3D gives depth sorting and, more to the point, DRAG-ROTATION for free. Rotation is the whole reason to be in 3D -- a fixed view of a volume tells you less than two scatters. """ spec = self._spec z = self._z_column frame = self.population() if not (spec.x and spec.y and z) or frame is None or frame.empty: return False if any(c not in frame.columns for c in (spec.x, spec.y, z)): return False from mpl_toolkits.mplot3d import Axes3D # noqa: F401 - registers 3d palette = active_palette() self._figure.clear() self._axes = {} ax = self._figure.add_subplot(projection="3d") self._apply_spin_speed(ax) x = pd.to_numeric(frame[spec.x], errors="coerce").to_numpy(float) y = pd.to_numeric(frame[spec.y], errors="coerce").to_numpy(float) zs = pd.to_numeric(frame[z], errors="coerce").to_numpy(float) finite = np.isfinite(x) & np.isfinite(y) & np.isfinite(zs) if self._volume_zoom > 1.0: self._apply_volume_zoom(ax) for values, (low, high) in ((x, ax.get_xlim3d()), (y, ax.get_ylim3d()), (zs, ax.get_zlim3d())): with np.errstate(invalid="ignore"): finite &= (values >= low) & (values <= high) if not self._draw_voxels(ax, x[finite], y[finite], zs[finite]): ax.scatter(x[finite], y[finite], zs[finite], s=max(1.0, self.POINT_SIZE_BASE / 4.0), c=self._density(x, y)[finite], cmap=self.point_colormap(), depthshade=False, linewidths=0.0, alpha=self.POINT_ALPHA) for axis, label in ((ax.xaxis, spec.x), (ax.yaxis, spec.y), (ax.zaxis, z)): axis.set_pane_color((0, 0, 0, 0)) axis.line.set_color(palette["fg_muted"]) ax.set_xlabel(spec.x, color=palette["fg"], fontsize=8) ax.set_ylabel(spec.y, color=palette["fg"], fontsize=8) ax.set_zlabel(z, color=palette["fg"], fontsize=8) ax.tick_params(colors=palette["fg_muted"], labelsize=7) self._figure.patch.set_alpha(0.0) ax.set_facecolor((0, 0, 0, 0)) try: ax.disable_mouse_rotation() except Exception: LOG.debug("could not take over 3d rotation", exc_info=True) if self._view_angles is not None: _set_view(ax, *self._view_angles) if self._volume_zoom != 1.0: self._apply_volume_zoom(ax) self._draw_anchor_aura(ax) self._axes = {(0, 0): ax} self._live = None self._draw_volume_gates(ax, frame, palette) self._draw_volume_handles(ax) self._canvas.draw_idle() return True #: Points above which the volume is drawn as voxels instead of dots. #: #: Not a taste threshold. A scatter of a million points in 3D is slower #: to draw than to compute, and every dot is drawn over by the ones in #: front of it -- so past this the picture stops improving and only the #: frame rate changes. Below it the dots ARE the better picture, because #: an individual object can be seen and clicked. VOXEL_THRESHOLD = 20000 def _draw_voxels(self, ax, x, y, z) -> bool: """Draw a three-dimensional occupancy grid when the data supports it. Voxel size represents the number of objects in each occupied bin. Return ``False`` when too few points justify binning so the caller can use the ordinary scatter representation. """ bins = int(getattr(self._settings, "voxel_bins", 0) or 0) if bins < 2 or len(x) < self.VOXEL_THRESHOLD: return False try: counts, edges = np.histogramdd( np.column_stack([x, y, z]), bins=(bins, bins, bins)) except Exception: LOG.debug("could not bin the volume", exc_info=True) return False filled = counts > 0 if not filled.any(): return False centres = [(e[:-1] + e[1:]) / 2.0 for e in edges] ix, iy, iz = np.nonzero(filled) weight = counts[filled] sizes = 6.0 + 40.0 * (weight / weight.max()) ax.scatter(centres[0][ix], centres[1][iy], centres[2][iz], s=sizes, c=weight, cmap=self.point_colormap(), depthshade=False, linewidths=0.0, alpha=min(1.0, self.POINT_ALPHA * 2)) return True
[docs] def volume_axis_map(self): """How screen pixels map to data on the two axes the user selected. Returns ``(x_column, y_column, invert)`` where ``invert(dx, dy)`` turns a movement in PIXELS into one in data units, or None when the view is not square-on. Built by projecting the data's own corners and measuring where they land, rather than by trusting a formula for the projection matrix: matplotlib has changed how `ax.M` is spelled more than once, and a drag that silently lands in the wrong measurement is worse than one that refuses. The first implementation chose the two axes that happened to face the camera. That made the X/Y/Z plane buttons cosmetic: turning the view could make a gate land on a different plane from the blue aura. The selected plane is now the source of truth. A genuinely edge-on plane is refused because its two data dimensions collapse to one screen line and no inverse exists. """ ax = self.axes_at(0, 0) spec = self._spec if ax is None or not hasattr(ax, "get_zlim"): return None columns = (spec.x, spec.y, self._z_column) plane = self.anchor_plane() if not all(columns) or plane is None: return None first, second, normal = plane limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) origin = [lo for lo, _hi in limits] spans = [hi - lo for lo, hi in limits] if any(not np.isfinite(s) or s == 0 for s in spans): return None def screen(point): """Project one data point to screen pixels.""" try: projected = ax.transData.transform( ax.get_proj() is not None and _project(ax, point) or (0, 0)) except Exception: return None return np.asarray(projected, dtype=float) base = screen(origin) if base is None: return None moves = [] for axis in range(3): point = list(origin) point[axis] += spans[axis] landed = screen(point) if landed is None: return None moves.append(landed - base) depth = columns.index(normal) kept = [columns.index(first), columns.index(second)] matrix = np.column_stack([moves[a] / spans[a] for a in kept]) if abs(float(np.linalg.det(matrix))) < 1e-9: return None inverse = np.linalg.inv(matrix) def invert(dx, dy): """Turn a screen-pixel delta back into a data delta. Uses the INVERSE of the projection taken once outside, so a drag is measured in the units the axis is in rather than in pixels -- the same drag near the origin and far from it means the same change. """ data = inverse @ np.asarray([dx, dy], dtype=float) return float(data[0]), float(data[1]) return first, second, invert, depth
[docs] def screen_to_volume(self, event): """Data coordinates on the explicitly selected anchor plane. :param event: a Matplotlib mouse event; its ``x`` and ``y`` display (pixel) coordinates are projected onto the anchor plane. :returns: ``(first column, value, second column, value)``, or ``None`` when there is no volume view. """ ax = self.axes_at(0, 0) mapping = self.volume_axis_map() if mapping is None or ax is None: return None first, second, invert, depth = mapping limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) origin = [lo for lo, _hi in limits] try: from mpl_toolkits.mplot3d import proj3d projected = ax.transData.inverted().transform(( float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0))) matrix = getattr(ax, "M", None) if matrix is None: matrix = ax.get_proj() inverse_matrix = np.linalg.inv(matrix) near = np.asarray(proj3d.inv_transform( projected[0], projected[1], -1.0, inverse_matrix), float).reshape(3) far = np.asarray(proj3d.inv_transform( projected[0], projected[1], 1.0, inverse_matrix), float).reshape(3) direction = far - near if abs(float(direction[depth])) > 1e-12: amount = (float(limits[depth][0]) - near[depth]) \ / direction[depth] point = near + amount * direction columns = (self._spec.x, self._spec.y, self._z_column) kept = [columns.index(first), columns.index(second)] if np.isfinite(point[kept]).all(): return (first, float(point[kept[0]]), second, float(point[kept[1]])) except Exception: LOG.debug("could not invert the 3D camera ray", exc_info=True) anchor = list(origin) anchor[depth] = limits[depth][0] base = np.asarray(ax.transData.transform(_project(ax, anchor)), dtype=float) dx = float(getattr(event, "x", 0) or 0) - base[0] dy = float(getattr(event, "y", 0) or 0) - base[1] first_delta, second_delta = invert(dx, dy) kept = [a for a in range(3) if a != depth] return (first, origin[kept[0]] + first_delta, second, origin[kept[1]] + second_delta)
[docs] def box_from_view(self) -> Optional[Gate]: """A box gate enclosing the volume's current limits. The view is the gesture. Spinning and zooming until a population fills the box is already the act of choosing it, and a rectangle dragged on a rotated projection has no defined extent along the axis pointing at the viewer -- reading one off would invent a number. """ ax = self.axes_at(0, 0) spec = self._spec if ax is None or not hasattr(ax, "get_zlim"): return None if not (spec.x and spec.y and self._z_column): return None return BoxGate.from_limits( "(unnamed)", (spec.x, spec.y, self._z_column), (ax.get_xlim(), ax.get_ylim(), ax.get_zlim()))
def _draw_box(self, ax, gate, colour) -> None: """The twelve edges of a box, drawn in the volume.""" frame = self.population() def bound(low, high, column): """One axis's limits: the gate's own, or the column's actual range. Falls back to the DATA when a side is unset, so a half-open gate still draws as a box rather than running off the axis. """ if low is not None and high is not None: return float(low), float(high) values = pd.to_numeric(frame[column], errors="coerce") \ if frame is not None and column in frame.columns else None lo = float(low) if low is not None else ( float(np.nanmin(values)) if values is not None else 0.0) hi = float(high) if high is not None else ( float(np.nanmax(values)) if values is not None else 1.0) return lo, hi x0, x1 = bound(gate.x_low, gate.x_high, gate.x_column) y0, y1 = bound(gate.y_low, gate.y_high, gate.y_column) z0, z1 = bound(gate.z_low, gate.z_high, gate.z_column) corners = [(x, y, z) for x in (x0, x1) for y in (y0, y1) for z in (z0, z1)] edges = [(a, b) for i, a in enumerate(corners) for b in corners[i + 1:] if sum(p != q for p, q in zip(a, b)) == 1] for a, b in edges: ax.plot([a[0], b[0]], [a[1], b[1]], [a[2], b[2]], color=colour, linewidth=self._line_width + 0.4, alpha=0.9) def _draw_ellipsoid(self, ax, gate, colour) -> None: """An ellipsoid as three rings, one in each pair of its axes.""" shown = self._plot_columns() centre = {gate.x_column: gate.x_centre, gate.y_column: gate.y_centre, gate.z_column: gate.z_centre} radius = {gate.x_column: gate.x_radius, gate.y_column: gate.y_radius, gate.z_column: gate.z_radius} turn = np.linspace(0.0, 2.0 * np.pi, 73) for first, second in ((0, 1), (0, 2), (1, 2)): ring = np.tile([centre[c] for c in shown], (len(turn), 1)) ring[:, first] += radius[shown[first]] * np.cos(turn) ring[:, second] += radius[shown[second]] * np.sin(turn) ax.plot(ring[:, 0], ring[:, 1], ring[:, 2], color=colour, linewidth=self._line_width + 0.6, alpha=0.9) def _draw_volume_gates(self, ax, frame, palette) -> None: """Show each shown gate's objects in the volume. A 2D gate is a statement about two of the three measurements, so in a volume it is a COLUMN through the cloud rather than a closed region. Marking its objects says exactly that, and says it without pretending the gate bounds a depth it never mentioned. """ spec = self._spec gates = self._gates preview = getattr(self, "_volume_preview", None) if preview is not None: gates = GateSet(list(self._gates.gates)) try: gates.add(preview) except GateError: gates = self._gates for gate in gates.gates: if not self.is_gate_enabled(gate.name): continue try: inside = gates.mask(frame, gate.name) except Exception: continue if not bool(np.any(inside)): continue colour = self.gate_colour(gate.name) if isinstance(gate, BoxGate) and gate.z_column == self._z_column: self._draw_box(ax, gate, colour) if isinstance(gate, _EllipsoidGate) and self._shows_columns(gate): self._draw_ellipsoid(ax, gate, colour) if isinstance(gate, ViewGate) and set(gate.columns) == { spec.x, spec.y, self._z_column}: self._draw_view_gate(ax, gate, colour) picked = frame.loc[inside] ax.scatter( pd.to_numeric(picked[spec.x], errors="coerce"), pd.to_numeric(picked[spec.y], errors="coerce"), pd.to_numeric(picked[self._z_column], errors="coerce"), s=18, facecolor="none", edgecolor=colour, linewidths=0.7, depthshade=False) def _on_button_release(self, event) -> None: """Square the volume up when a spin ends, if the setting says so. `snap_to_axis` was declared, given a control, saved and reloaded, and READ BY NOTHING -- a control that turns nothing is a promise the application does not keep. This is where it turns something. Only in 3D, and only when the view has actually been turned: snapping a volume nobody rotated would move a view the user set deliberately. """ if self._mode not in ("3D", "xD"): return if not bool(getattr(self._settings, "snap_to_axis", False)): return if self._view_angles is None: return if self._last_gesture == "draw": return try: self.snap_to_nearest_axis() except Exception: LOG.debug("could not snap the view", exc_info=True) def _apply_spin_speed(self, axes) -> None: """Scale how far a drag turns the volume. matplotlib has no public setting for this: ``Axes3D`` converts the drag straight into degrees inside ``_on_move``. So the method is WRAPPED rather than reimplemented -- the wrapper scales the reported cursor movement and lets matplotlib do the rest, which keeps the rotation matplotlib's and the speed ours. Guarded end to end: a matplotlib whose internals moved leaves the rotation at its normal speed, which is a setting not taking effect rather than a volume that will not turn. """ speed = float(getattr(self._settings, "spin_speed", 1.0) or 1.0) original = getattr(axes, "_on_move", None) if original is None or getattr(original, "_spacr_wrapped", False): return if abs(speed - 1.0) < 1e-9: return def scaled(event): """Scale one wheel step by the spin box's own speed setting.""" try: start_x = getattr(axes, "_sx", None) start_y = getattr(axes, "_sy", None) if start_x is not None and event.x is not None: event.x = start_x + (event.x - start_x) * speed if start_y is not None and event.y is not None: event.y = start_y + (event.y - start_y) * speed except Exception: LOG.debug("could not scale the spin", exc_info=True) return original(event) scaled._spacr_wrapped = True try: axes._on_move = scaled except Exception: LOG.debug("this matplotlib does not allow a spin-speed wrap", exc_info=True)
[docs] def snap_to_nearest_axis(self) -> Tuple[float, float]: """Turn the volume square-on to whichever axis it is nearest. A volume stopped at an arbitrary angle cannot be read off at all -- the point of snapping is that a 3D gate is always finally judged from a view where one measurement is flat. """ axes = self.axes_at(0, 0) if axes is None: return (0.0, 0.0) elevation = min((0.0, 90.0, -90.0), key=lambda e: abs(e - float(axes.elev))) azimuth = min((0.0, 90.0, 180.0, 270.0, 360.0), key=lambda a: abs(a - (float(axes.azim) % 360))) azimuth = azimuth % 360 _set_view(axes, elevation, azimuth, 0.0) self._view_angles = (elevation, azimuth, 0.0) self._canvas.draw_idle() return (elevation, azimuth)
[docs] def render_now(self) -> None: """Draw the parent's population, then the gates on top of it. In 3D the volume replaces all of it: the gate tools are 2D gestures on a flat axes, and running them against a rotated projection would produce gates whose coordinates mean nothing. """ if self._mode in ("3D", "xD") and self._render_volume(): return frame, self._frame = self._frame, self.population() try: super().render_now() finally: self._frame = frame self._draw_gates()
def _draw_gates(self) -> None: """Outline every gate that is drawn on these two columns.""" self._artists = [] axes = self.panel_axes() if not axes: return palette = active_palette() frame = self.population() for ax in axes.values(): for gate in self._gates.gates: if not self._gate_is_on_these_axes(gate): continue if not self.is_gate_enabled(gate.name): continue if self._highlight_gated: self._highlight(ax, gate, frame, palette) self._outline(ax, gate, palette) self._draw_handles(ax, gate, palette) if self._pending: self._outline_pending(ax, palette) self._canvas.draw_idle() def _highlight(self, ax, gate: Gate, frame, palette) -> None: """Mark the objects inside ``gate``, leaving every other point alone. This is what replaced replotting the gate's population: "i want it to highlight the datapoints in the gate and show the gate but also show the rest of the graph." The mask comes from the GateSet, so a child gate marks its own population and not its parent's. Failure here is silent and total -- a gate whose columns are missing from this table simply is not highlighted. The outline still draws, so the user sees the gate; a traceback out of a paint path would take the whole plot with it, and the highlight is decoration (INVARIANTS 10). """ spec = self._spec if frame is None or frame.empty or not (spec.x and spec.y): return if spec.x not in frame.columns or spec.y not in frame.columns: return try: inside = self._gates.mask(frame, gate.name) except Exception: LOG.debug("cannot highlight %s here", gate.name, exc_info=True) return if inside is None or not bool(inside.any()): return marked = frame.loc[inside] self._artists.append( ax.scatter(marked[spec.x], marked[spec.y], s=14, facecolor="none", edgecolor=self.gate_colour(gate.name), linewidths=0.7, zorder=6)) def _as_flat(self, gate: Gate) -> Gate: """A box seen from the front, so the 2D tools can draw and edit it. Its outline, handles and drag then all work unchanged, and the depth the flat view cannot express is left alone rather than silently reset. """ if isinstance(gate, BoxGate): return gate.to_rect() return gate def _gate_is_on_these_axes(self, gate: Gate) -> bool: """Whether ``gate`` belongs to the measurements currently plotted. A gate is a statement about two named columns. Drawing one on a different pair is meaningless -- the outline would sit at coordinates that mean something else entirely -- and NOT drawing it when the user comes back to its own pair is how a gate seems to have vanished. A one-column gate (a threshold) needs only its column on screen, on either axis: a histogram puts it on x, and a scatter may put it on either. """ spec = self._spec showing = {c for c in (getattr(spec, "x", None), getattr(spec, "y", None)) if c} if isinstance(gate, BoxGate): return {gate.x_column, gate.y_column} <= showing needed = set(gate.columns) if not needed: return False return needed <= showing
[docs] def gate_colour(self, name: str) -> str: """The colour a gate is drawn in, stable for the life of the set. By POSITION in the gate set, so a gate keeps its colour while others are added, and so the outline, the ringed objects and the row in the gate list all agree -- which is the point: a plot with four gates on it should be readable without clicking each one. Falls back to the accent when a gate is not in the set (a shape being dragged out has no position yet). :param name: the gate's name. """ names = list(self._gates.names) if name not in names: return active_palette()["accent"] return GATE_COLOURS[names.index(name) % len(GATE_COLOURS)]
def _outline(self, ax, gate: Gate, palette) -> None: """Draw ``gate`` if it is a gate on the columns currently plotted. A gate on other columns is not drawn rather than approximated onto these axes: an outline in the wrong units is worse than no outline. """ accent = self.gate_colour(gate.name) if isinstance(gate, ThresholdGate): if gate.column not in (self._spec.x, self._spec.y): return for bound in (gate.low, gate.high): if bound is not None: self._artists.append( ax.axvline(bound, color=accent, linewidth=1.4, linestyle="--", zorder=7)) return if not (self._spec.x == getattr(gate, "x_column", None) and self._spec.y == getattr(gate, "y_column", None)): return from matplotlib.patches import Polygon as MplPolygon points = self._gate_points(ax, self._as_flat(gate)) if not points: return patch = MplPolygon(points, closed=True, fill=False, edgecolor=accent, linewidth=self._line_width, zorder=7) ax.add_patch(patch) self._artists.append(patch) ax.annotate(gate.name, points[0], color=accent, fontsize=7, xytext=(2, 2), textcoords="offset points", zorder=8) def _gate_points(self, ax, gate: Gate) -> List[Tuple[float, float]]: """The outline of ``gate`` as a closed run of points. One geometry for the solid outline, the dashed ghost and the drag preview, so a gate cannot be drawn one shape and committed as another -- which is exactly what happened to the oval. EllipseGate had NO branch here at all: an oval was previewed while being dragged and then vanished the moment it became a gate. It is approximated as a polygon rather than an `Ellipse` patch so the ghost and the outline share this one path. """ if isinstance(gate, RectGate): x0, x1, y0, y1 = self._rect_bounds(ax, gate) return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] if isinstance(gate, PolygonGate): return list(gate.vertices) if isinstance(gate, EllipseGate): angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False) return [(gate.x_centre + gate.x_radius * float(np.cos(a)), gate.y_centre + gate.y_radius * float(np.sin(a))) for a in angles] return [] def _view(self, ax) -> Tuple[float, float, float, float]: """The visible limits, for placing handles on unbounded sides.""" x0, x1 = ax.get_xlim() y0, y1 = ax.get_ylim() return float(x0), float(x1), float(y0), float(y1) def _handles_for(self, ax, gate: Gate) -> Tuple[Handle, ...]: """``gate``'s anchor points, or none if it is not on these axes.""" if not self._gate_is_on_these_axes(gate): return () try: return self._as_flat(gate).handles(self._view(ax)) except Exception: LOG.debug("no handles for %s", gate.name, exc_info=True) return () def _draw_handles(self, ax, gate: Gate, palette) -> None: """Draw ``gate``'s anchors: squares for corners, circles for sides.""" handles = self._handles_for(ax, gate) if not handles: return accent = self.gate_colour(gate.name) for corner, marker in ((True, "s"), (False, "o")): picked = [h for h in handles if h.corner is corner] if not picked: continue self._artists.append(ax.plot( [h.x for h in picked], [h.y for h in picked], linestyle="none", marker=marker, markersize=5, markerfacecolor=palette["bg"], markeredgecolor=accent, markeredgewidth=1.2, zorder=9)[0])
[docs] def handle_at(self, event) -> Optional[Tuple[str, str]]: """The anchor under the pointer as ``(gate name, role)``, or None. Measured in pixels for the same reason polygon-closing is: a tolerance in data units would be unusable on one axis whenever the two measurements have different ranges, which is nearly always. Only ENABLED gates are grabbable. A hidden gate is not on screen, and an invisible anchor that catches the mouse is indistinguishable from the plot being broken. :param event: a Matplotlib mouse event; its ``inaxes`` and its ``x`` and ``y`` display (pixel) coordinates are read. """ ax = getattr(event, "inaxes", None) ex, ey = getattr(event, "x", None), getattr(event, "y", None) if ax is None or ex is None or ey is None: return None best: Optional[Tuple[str, str]] = None best_distance = self.HANDLE_RADIUS_PX for gate in self._gates.gates: if not self.is_gate_enabled(gate.name): continue for handle in self._handles_for(ax, gate): try: px, py = ax.transData.transform((handle.x, handle.y)) except Exception: continue distance = ((px - ex) ** 2 + (py - ey) ** 2) ** 0.5 if distance <= best_distance: best_distance = distance best = (gate.name, handle.role) return best
def _clear_ghost(self) -> None: """Remove the shape being drawn. EACH REMOVAL IS GUARDED: an artist matplotlib has already disposed of raises on removal, and one stale artist must not leave the rest of the ghost on screen. """ for artist in self._ghost: try: artist.remove() except Exception: pass self._ghost = [] def _show_ghost(self, gate: Optional[Gate]) -> None: """Draw ``gate`` dashed, as the placeholder following the mouse. The gate being dragged is "picked up": its prospective shape is drawn dashed while the mouse moves and only becomes real on release. The committed gate stays drawn underneath, so the user can see where it was as well as where it is going. Only the ghost's own artists are touched. Redrawing every gate on every motion event would re-mask the whole table per gate per mouse move, which is what made applying the move on release necessary in the first place. """ self._clear_ghost() axes = self.panel_axes() if gate is None or not axes: self._canvas.draw_idle() return from matplotlib.patches import Polygon as MplPolygon palette = active_palette() for ax in axes.values(): if isinstance(gate, ThresholdGate): for bound in (gate.low, gate.high): if bound is not None: self._ghost.append(ax.axvline( bound, color=palette["warning"], linewidth=1.4, linestyle=":", zorder=10)) continue points = self._gate_points(ax, self._as_flat(gate)) if not points: continue patch = MplPolygon(points, closed=True, fill=False, edgecolor=palette["warning"], linewidth=1.4, linestyle="--", zorder=10) ax.add_patch(patch) self._ghost.append(patch) self._canvas.draw_idle() def _dragged_to(self, event) -> Optional[Gate]: """The gate as it would be if the mouse were released here. One function for both gestures and for both the ghost and the commit, so the dashed shape cannot promise something the release does not do. """ if event.inaxes is None or event.xdata is None or event.ydata is None: return None x, y = float(event.xdata), float(event.ydata) if self._resize is not None: name, role = self._resize try: return self._gates.get(name).with_handle(role, x, y) except Exception: LOG.debug("cannot resize %s by %s", name, role, exc_info=True) return None name = self._move_name start = self._move_from if not name or start is None: return None try: return self._gates.get(name).translated(x - start[0], y - start[1]) except Exception: return None def _rect_bounds(self, ax, gate: RectGate) -> Tuple[float, float, float, float]: """A rectangle's corners, with an unbounded side taken to the axis.""" x_low, x_high = ax.get_xlim() y_low, y_high = ax.get_ylim() return (gate.x_low if gate.x_low is not None else x_low, gate.x_high if gate.x_high is not None else x_high, gate.y_low if gate.y_low is not None else y_low, gate.y_high if gate.y_high is not None else y_high) def _volume_face_point(self, ax, u: float, v: float): """Where a pending vertex ``(u, v)`` sits in the volume. The two numbers a pending vertex holds are its position on the plane the polygon is being clicked out on -- NOT ``x`` and ``y``. Which of the three axes they belong to is `_pending_plane`, and the third one is pinned to the face the blue aura is drawn on, so the trail of markers lands on the surface the user is drawing on. :returns: ``[x, y, z]``, or None once the plane the vertices were placed on is no longer among the three measurements on screen -- which is what changing a picker mid-polygon does. """ if not self._pending_plane: return None columns = (self._spec.x, self._spec.y, self._z_column) first, second = self._pending_plane normal = next((c for c in columns if c not in (first, second)), "") if not normal: return None try: limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) point = [0.0, 0.0, 0.0] point[columns.index(first)] = float(u) point[columns.index(second)] = float(v) point[columns.index(normal)] = float( limits[columns.index(normal)][0]) except Exception: LOG.debug("the pending plane is no longer on screen", exc_info=True) return None return point def _outline_pending(self, ax, palette) -> None: """Trace the vertices placed so far.""" if self._in_volume(): self._outline_pending_in_volume(ax, palette) return xs = [v[0] for v in self._pending] ys = [v[1] for v in self._pending] self._artists.append(ax.plot( xs, ys, color=palette["warning"], linewidth=1.2, marker="o", markersize=3, zorder=8)[0]) def _outline_pending_in_volume(self, ax, palette) -> None: """Trace the pending vertices on the face they were placed on. Two things a flat `ax.plot(xs, ys)` on an `Axes3D` got wrong, and both of them stopped a 3D polygon being finished at all: the vertices were drawn as ``(x, y)`` at a depth of zero, which is neither the measurements the user clicked nor a depth the data reaches; and a flat plot AUTOSCALES all three limits, so the volume was rescaled by the act of drawing on it. Every click moved the picture and the vertex already placed slid out from under the cursor that placed it -- measured at 104 px against a `CLOSE_RADIUS_PX` of 12, so clicking the first vertex back added a fourth one instead of closing the shape. The limits are therefore put back afterwards. A half-drawn polygon is a GESTURE, not data, and nothing about a gesture belongs in the range of the axes it is drawn over. """ points = [self._volume_face_point(ax, u, v) for u, v in self._pending] if any(point is None for point in points): return limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) try: line = ax.plot( [p[0] for p in points], [p[1] for p in points], [p[2] for p in points], color=palette["warning"], linewidth=1.2, marker="o", markersize=3, zorder=8)[0] except Exception: LOG.debug("could not draw the pending outline", exc_info=True) return finally: ax.set_xlim3d(*limits[0]) ax.set_ylim3d(*limits[1]) ax.set_zlim3d(*limits[2]) self._artists.append(line)
[docs] def gate_at(self, x: float, y: float) -> Optional[str]: """Name of the topmost gate containing ``(x, y)``, or None. Topmost = last drawn, which is the one the user sees on top and therefore the one they mean by clicking there. Deliberately does NOT consult `population()`. Hit-testing a gate is pure geometry -- is this coordinate inside this shape -- and needs no rows at all. Asking for the population first meant that whenever it was unavailable, dragging died silently: `population()` returns None when the active gate no longer exists, which is exactly the state left behind by DELETING a gate. The remaining gate then looked "fixed and I cannot move". Only gates on the current axes are tested, so a gate belonging to a different pair cannot be grabbed invisibly. :param x: the point's x coordinate in data units; tested as the value of each gate's first column. :param y: the point's y coordinate in data units; tested as the value of a two-column gate's second column. """ probe = pd.DataFrame({}) hit: Optional[str] = None for gate in self.gates.gates: if not self._gate_is_on_these_axes(gate): continue columns = gate.columns if not columns: continue try: probe = pd.DataFrame({columns[0]: [float(x)]}) if len(columns) > 1: probe[columns[1]] = [float(y)] if bool(self._as_flat(gate).mask(probe)[0]): hit = gate.name except Exception: continue return hit
[docs] def set_spin_axis(self, axis: str) -> None: """Constrain subsequent volume rotation to one data axis, or free it. ``"x"``, ``"y"`` and ``"z"`` turn the volume about that measurement's own axis, which stays put on screen. ``""`` is the default: a trackball that turns about both screen axes at once, so every orientation is reachable. Anything else falls back to free. :param axis: ``"x"``, ``"y"``, ``"z"``, or ``""`` for free rotation. """ self._spin_axis = axis if axis in ("x", "y", "z") else ""
def _in_volume(self) -> bool: """Whether the volume is what is currently drawn.""" return (self._mode in ("3D", "xD") and hasattr(self.axes_at(0, 0), "get_zlim")) def _plot_columns(self) -> Tuple[str, str, str]: """The three measurements on the volume's x, y and z axes.""" return (self._spec.x, self._spec.y, self._z_column) def _shows_columns(self, gate: Gate) -> bool: """Whether ``gate`` reads exactly the three measurements on screen.""" shown = self._plot_columns() return all(shown) and set(gate.columns) == set(shown) def _volume_xyz(self, frame=None) -> Optional[np.ndarray]: """The population as an ``(n, 3)`` array in the volume's axis order.""" frame = self.population() if frame is None else frame shown = self._plot_columns() if frame is None or not all(shown) or any( c not in frame.columns for c in shown): return None return np.column_stack([ pd.to_numeric(frame[c], errors="coerce").to_numpy(float) for c in shown]) def _inside_outline(self, outline) -> Optional[np.ndarray]: """Which population rows project inside a screen outline. :param outline: the outline's vertices in canvas pixels. :returns: a boolean array, or None when the volume cannot project. """ ax = self.axes_at(0, 0) points = self._volume_xyz() if ax is None or points is None or len(outline) < 3: return None pixels = _screen_points(ax, points) if pixels is None: return None return points_in_polygon(pixels[:, 0], pixels[:, 1], list(outline)) def _clear_live(self) -> None: """Remove the live highlight of a shape being drawn.""" artist, self._live = getattr(self, "_live", None), None if artist is not None: try: artist.remove() except Exception: pass def _show_live(self, outline) -> None: """Ring the objects inside the shape being drawn, as it is drawn.""" self._clear_live() ax = self.axes_at(0, 0) if ax is None or not self._in_volume() or len(outline) < 3: return inside = self._inside_outline(outline) points = self._volume_xyz() if inside is None or points is None or not inside.any(): return picked = points[inside] limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) try: self._live = ax.scatter( picked[:, 0], picked[:, 1], picked[:, 2], s=22, facecolor="none", edgecolor=active_palette()["warning"], linewidths=0.9, depthshade=False) finally: ax.set_xlim3d(*limits[0]) ax.set_ylim3d(*limits[1]) ax.set_zlim3d(*limits[2]) def _view_polygon_click(self, event) -> bool: """Place one vertex of a polygon drawn on the 3D view. A click near the first vertex, or a double-click, closes the polygon once it has three corners; the gate is then swept along the line of sight like the lasso. :returns: True when the click belonged to the polygon. """ if (not self._in_volume() or self.drag_mode() != "draw" or _is_right_button(event) or self.volume_shape() != "view_polygon" or event.inaxes is None): return False point = (float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0)) self._last_gesture = "draw" polygon = self._view_polygon if len(polygon) >= 3: first = polygon[0] near = np.hypot(point[0] - first[0], point[1] - first[1]) \ <= self.CLOSE_RADIUS_PX if near or bool(getattr(event, "dblclick", False)): self._finish_view_polygon() return True if not polygon or np.hypot(point[0] - polygon[-1][0], point[1] - polygon[-1][1]) > 3.0: polygon.append(point) self.polygon_changed.emit(len(polygon)) self._show_view_polygon(None) return True def _show_view_polygon(self, event) -> None: """Draw the polygon so far, with a rubber band to the pointer.""" from matplotlib.lines import Line2D from matplotlib.transforms import IdentityTransform self._clear_ghost() outline = list(self._view_polygon) if event is not None and getattr(event, "x", None) is not None: outline.append((float(event.x), float(event.y))) if not outline: return xs = [p[0] for p in outline] + [outline[0][0]] ys = [p[1] for p in outline] + [outline[0][1]] line = Line2D(xs, ys, transform=IdentityTransform(), color=active_palette()["warning"], linewidth=1.2, linestyle="--", marker="o", markersize=3) self._figure.add_artist(line) self._ghost.append(line) self._show_live(outline) self._canvas.draw_idle() def _finish_view_polygon(self) -> None: """Turn the clicked polygon into a gate swept through the view.""" outline, self._view_polygon = list(self._view_polygon), [] self._clear_ghost() self._clear_live() self.polygon_changed.emit(0) gate = self.gate_from_screen_outline(outline) if gate is None: self.depth_requested.emit(tr( "That outline encloses nothing: click around the objects " "to keep.")) self._canvas.draw_idle() return self.depth_requested.emit("") self.gate_drawn.emit(gate) def _fitted_gate_from_outline(self, outline) -> Optional[Gate]: """A box or ellipsoid fitted to the objects a screen shape encloses. The rectangle picks the objects; their range on each of the three measurements sets the box's sides, or the ellipsoid's centre and radii. The handles then refine it from any angle. :param outline: the outline's vertices in canvas pixels. :returns: the gate, or None when the outline encloses nothing. """ inside = self._inside_outline(outline) points = self._volume_xyz() if inside is None or points is None: return None picked = points[inside] picked = picked[np.isfinite(picked).all(axis=1)] if not len(picked): return None low, high = picked.min(axis=0), picked.max(axis=0) spans = np.where(high > low, high - low, 1e-9) columns = self._plot_columns() if self.volume_shape() == "ellipsoid": centre, radius = (low + high) / 2.0, spans / 2.0 return _EllipsoidGate( name="(unnamed)", x_column=columns[0], y_column=columns[1], z_column=columns[2], x_centre=float(centre[0]), y_centre=float(centre[1]), z_centre=float(centre[2]), x_radius=float(radius[0]), y_radius=float(radius[1]), z_radius=float(radius[2])) return BoxGate.from_limits( "(unnamed)", columns, [(float(a), float(b)) for a, b in zip(low, high)]) def _handle_gate(self) -> Optional[Gate]: """The gate whose handles are shown: the selected one, if it has any.""" name = self._active if not name or name not in self._gates.names: return None preview = self._volume_preview gate = preview if preview is not None and preview.name == name \ else self._gates.get(name) if not self.is_gate_enabled(name) or not self._shows_columns(gate): return None if isinstance(gate, BoxGate) and None in ( gate.x_low, gate.x_high, gate.y_low, gate.y_high, gate.z_low, gate.z_high): return None if isinstance(gate, (BoxGate, _EllipsoidGate, ViewGate)): return gate return None def _volume_handles(self, gate: Gate) -> List[Tuple[str, np.ndarray]]: """``gate``'s handles as ``(role, point)`` in the volume's axis order. A box offers its centre and the middle of each face, an ellipsoid its centre and the tip of each radius, and a gate drawn through the view its centre only, at the middle of the objects it keeps. """ shown = self._plot_columns() def placed(values: Dict[str, float]) -> np.ndarray: """A point given by column, laid out in the volume's order.""" return np.asarray([values[c] for c in shown], dtype=float) handles: List[Tuple[str, np.ndarray]] = [] if isinstance(gate, BoxGate): sides = {gate.x_column: (gate.x_low, gate.x_high, "x"), gate.y_column: (gate.y_low, gate.y_high, "y"), gate.z_column: (gate.z_low, gate.z_high, "z")} middle = {c: (lo + hi) / 2.0 for c, (lo, hi, _p) in sides.items()} handles.append(("centre", placed(middle))) for column, (low, high, prefix) in sides.items(): for role, value in ((f"{prefix}_low", low), (f"{prefix}_high", high)): handles.append((role, placed({**middle, column: value}))) elif isinstance(gate, _EllipsoidGate): middle = {gate.x_column: gate.x_centre, gate.y_column: gate.y_centre, gate.z_column: gate.z_centre} handles.append(("centre", placed(middle))) for prefix in ("x", "y", "z"): column = getattr(gate, f"{prefix}_column") radius = getattr(gate, f"{prefix}_radius") for role, sign in ((f"{prefix}_low", -1.0), (f"{prefix}_high", 1.0)): handles.append((role, placed( {**middle, column: middle[column] + sign * radius}))) elif isinstance(gate, ViewGate): points = self._volume_xyz() frame = self.population() if points is None or frame is None: return [] try: inside = gate.mask(frame) except Exception: return [] kept = points[inside] kept = kept[np.isfinite(kept).all(axis=1)] if len(kept): handles.append(("centre", kept.mean(axis=0))) return handles def _draw_volume_handles(self, ax) -> None: """Draw the selected gate's handles as rings the pointer can grab.""" gate = self._handle_gate() if gate is None: return handles = self._volume_handles(gate) if not handles: return points = np.asarray([p for _r, p in handles]) limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) colour = self.gate_colour(gate.name) try: ax.scatter(points[:, 0], points[:, 1], points[:, 2], s=46, facecolor=colour, edgecolor=active_palette()["fg"], linewidths=1.4, depthshade=False, zorder=10) finally: ax.set_xlim3d(*limits[0]) ax.set_ylim3d(*limits[1]) ax.set_zlim3d(*limits[2]) def _grab_volume_handle(self, event) -> bool: """Start pulling the handle under the pointer, if there is one.""" if not self._in_volume() or _is_right_button(event) \ or event.inaxes is None: return False gate = self._handle_gate() ax = self.axes_at(0, 0) if gate is None or ax is None: return False handles = self._volume_handles(gate) if not handles: return False pixels = _screen_points(ax, [p for _r, p in handles]) if pixels is None: return False x = float(getattr(event, "x", 0) or 0) y = float(getattr(event, "y", 0) or 0) distance = np.hypot(pixels[:, 0] - x, pixels[:, 1] - y) index = int(np.argmin(distance)) if float(distance[index]) > self.HANDLE_RADIUS_PX * 1.5: return False role, point = handles[index] self._last_gesture = "draw" self._handle_drag = (gate.name, role, (x, y), gate, point) return True def _pixels_per_unit(self, ax, point) -> Optional[np.ndarray]: """How far one data unit along each volume axis moves on screen. :returns: a 2 x 3 array of pixels per unit, one column per axis. """ limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) steps = [max(abs(hi - lo), 1e-12) * 1e-3 for lo, hi in limits] base = np.asarray(point, dtype=float) probes = [base] + [base + np.eye(3)[a] * steps[a] for a in range(3)] pixels = _screen_points(ax, probes) if pixels is None or not np.isfinite(pixels).all(): return None return np.column_stack([(pixels[a + 1] - pixels[0]) / steps[a] for a in range(3)]) def _handle_moved(self, event) -> Optional[Gate]: """The gate being pulled, as it would be with the pointer here.""" if self._handle_drag is None: return None _name, role, start, gate, point = self._handle_drag ax = self.axes_at(0, 0) if ax is None: return None jacobian = self._pixels_per_unit(ax, point) if jacobian is None: return None drag = np.array([float(getattr(event, "x", 0) or 0) - start[0], float(getattr(event, "y", 0) or 0) - start[1]]) shown = self._plot_columns() if role == "centre": limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) spans = np.array([max(abs(hi - lo), 1e-12) for lo, hi in limits]) delta = (np.linalg.pinv(jacobian * spans) @ drag) * spans return self._moved_gate(gate, dict(zip(shown, delta)), point, delta) prefix, side = role.split("_") column = getattr(gate, f"{prefix}_column") direction = jacobian[:, shown.index(column)] length = float(direction @ direction) if length < 1e-9: return gate amount = float(drag @ direction) / length if isinstance(gate, BoxGate): field_name = f"{prefix}_{side}" return replace(gate, **{ field_name: float(getattr(gate, field_name)) + amount}) if isinstance(gate, _EllipsoidGate): radius = float(getattr(gate, f"{prefix}_radius")) radius = radius + amount if side == "high" else radius - amount return replace(gate, **{f"{prefix}_radius": abs(radius)}) return gate def _moved_gate(self, gate: Gate, by_column: Dict[str, float], point, delta) -> Gate: """``gate`` moved through the volume by ``by_column`` data units.""" if isinstance(gate, BoxGate): changes = {} for prefix in ("x", "y", "z"): shift = by_column[getattr(gate, f"{prefix}_column")] for side in ("low", "high"): name = f"{prefix}_{side}" changes[name] = float(getattr(gate, name)) + shift return replace(gate, **changes) if isinstance(gate, _EllipsoidGate): return replace(gate, **{ f"{p}_centre": float(getattr(gate, f"{p}_centre")) + by_column[getattr(gate, f"{p}_column")] for p in ("x", "y", "z")}) if isinstance(gate, ViewGate): shown = self._plot_columns() before = {c: float(v) for c, v in zip(shown, point)} after = {c: float(v) + float(d) for c, v, d in zip(shown, point, delta)} old = gate.project(*[[before[c]] for c in gate.columns]) new = gate.project(*[[after[c]] for c in gate.columns]) dx, dy = float(new[0][0] - old[0][0]), float(new[1][0] - old[1][0]) if np.isfinite(dx) and np.isfinite(dy): return gate.translated(dx, dy) return gate def _drag_volume_handle(self, event) -> None: """Show the gate following the handle being pulled.""" edited = self._handle_moved(event) if edited is None: return self._volume_preview = edited self.render_now() def _release_volume_handle(self, event) -> None: """Finish pulling a handle and hand the edited gate to the panel.""" edited = self._handle_moved(event) original = self._handle_drag[3] if self._handle_drag else None self._handle_drag = None self._volume_preview = None if edited is None or edited == original: self.render_now() return self.gate_edited.emit(edited) def _volume_press(self, event) -> bool: """Start a gesture on the volume: a handle, a polygon vertex or a drag. :returns: True when the volume consumed the press. """ if not self._in_volume(): return False if self._grab_volume_handle(event): return True if self._view_polygon_click(event): return True return self._volume_press_gesture(event) def _volume_press_gesture(self, event) -> bool: """Start the gesture selected by Spin/Draw. The gate tools must not see it. That is the bug behind "i cant zoom in or spin on any of the axees. if i press pollygon and tried to draw a gate, then i could all of a suded spinn the graph" -- the 2D press handler was consuming the drag, and only the polygon tool, which ignores drags, let it through to matplotlib. """ if not self._in_volume(): return False if event.inaxes is None: return True drawing = self.drag_mode() == "draw" and not _is_right_button(event) self._last_gesture = "draw" if drawing else "spin" if drawing: if self.volume_shape() in VIEW_SHAPES + _FITTED_SHAPES: self._lasso = [(float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0))] return True if self.volume_shape() == "polygon": return False if self._pending_volume_gate is not None: self._depth_drag_from = ( float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0)) return True corner = self.screen_to_volume(event) if corner is not None: self._volume_drag = corner return True self._spin_from = (float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0)) return True def _volume_motion(self, event) -> bool: """Track a 3-D gate's drag, if one is in progress. :param event: the matplotlib motion event. :returns: True when this consumed the event. """ if not self._in_volume(): return False if self._handle_drag is not None: self._drag_volume_handle(event) return True if self._view_polygon: self._show_view_polygon(event) return True if self._lasso is not None: self._extend_lasso(event) return True if self._volume_drag is not None: self._show_volume_drag(event) return True if self._depth_drag_from is not None: bounds = self._depth_bounds_from_drag(self._depth_drag_from, event) if bounds is not None: low, high = bounds if low is None and high is None: self.depth_requested.emit( "Full depth selected — release to create the gate.") else: self.depth_requested.emit( f"Depth {low:.4g} to {high:.4g} — release to create " "the gate.") return True if self._spin_from is None or event.inaxes is None: return True ax = self.axes_at(0, 0) if ax is None or not hasattr(ax, "view_init"): return True x, y = float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0) dx, dy = x - self._spin_from[0], y - self._spin_from[1] self._spin_from = (x, y) self._view_angles = self._turned(ax, dx, dy) _set_view(ax, *self._view_angles) self._canvas.draw_idle() return True #: Degrees the volume turns per pixel of drag, before `spin_speed`. DEGREES_PER_PIXEL = 0.5 def _turned(self, ax, dx: float, dy: float) -> Tuple[float, float, float]: """The camera angles after a drag of ``(dx, dy)`` pixels. Free (the default) is an orbit: a sideways drag changes the azimuth and an upward drag the elevation, as matplotlib's own rotation does, so the vertical axis stays upright and the camera never rolls. There is no clamp and no snap: the view stays at whatever angle the drag leaves, over the pole included. Locked to a data axis, the drag turns the volume about that axis only, measured across the axis as it lies on screen so the gesture reads the same whichever way the axis points. """ speed = float(getattr(self._settings, "spin_speed", 1.0) or 1.0) step = self.DEGREES_PER_PIXEL * speed elevation = float(getattr(ax, "elev", 0.0) or 0.0) azimuth = float(getattr(ax, "azim", 0.0) or 0.0) roll = float(getattr(ax, "roll", 0.0) or 0.0) axis = self._spin_axis if axis not in ("x", "y", "z"): return (_wrapped_angle(elevation - dy * step), _wrapped_angle(azimuth - dx * step), roll) u, v, _w = view_axes(elevation, azimuth, roll) direction = np.zeros(3) direction[{"x": 0, "y": 1, "z": 2}[axis]] = 1.0 across = np.array([float(direction @ u), float(direction @ v)]) length = float(np.hypot(*across)) if length < 0.2: amount = dx else: amount = (dx * across[1] - dy * across[0]) / length return rotate_about_world(elevation, azimuth, roll, axis, amount * step) def _volume_release(self, event) -> bool: """Finish a 3-D gate's drag. :param event: the matplotlib release event. :returns: True when this consumed the event. """ if not self._in_volume(): return False if self._handle_drag is not None: self._release_volume_handle(event) return True if self._lasso is not None: self._extend_lasso(event) outline, self._lasso = self._lasso_outline(), None self._clear_ghost() self._clear_live() if self.volume_shape() in _FITTED_SHAPES: gate = self._fitted_gate_from_outline(outline) else: gate = self.gate_from_screen_outline(outline) if gate is None: self.depth_requested.emit(tr( "That outline encloses nothing: drag around the objects " "to keep.")) self._canvas.draw_idle() return True self.depth_requested.emit("") self.gate_drawn.emit(gate) return True if self._depth_drag_from is not None: bounds = self._depth_bounds_from_drag(self._depth_drag_from, event) self._depth_drag_from = None if bounds is None: self.depth_requested.emit( "That depth cannot be read from this angle. Choose Spin, " "turn the normal axis into view, then choose Draw and " "drag the depth again.") return True self._finish_volume_depth(bounds) return True if self._volume_drag is not None: gate = self._gate_from_volume_drag(event) self._volume_drag = None self._clear_ghost() if gate is not None: self._begin_volume_depth(gate) else: self.render_now() return True self._spin_from = None return True def _volume_scroll(self, event) -> bool: """Zoom the volume by scaling all three axes about their centres.""" if not self._in_volume(): return False ax = self.axes_at(0, 0) if ax is None or not hasattr(ax, "get_zlim"): return True step = getattr(event, "step", 0) or ( 1 if getattr(event, "button", "") == "up" else -1) self._volume_zoom = max(0.05, min(50.0, self._volume_zoom * (1.25 ** step))) self._apply_volume_zoom(ax) if ax in getattr(self._figure, "axes", ()): self.render_now() else: self._canvas.draw_idle() return True def _show_volume_drag(self, event) -> None: """The rectangle being swept, drawn flat on the snapped view.""" corner = self.screen_to_volume(event) start = self._volume_drag ax = self.axes_at(0, 0) if corner is None or start is None or ax is None: return self._clear_ghost() first, x0, second, y0 = start _f, x1, _s, y1 = corner limits = {"x": ax.get_xlim3d(), "y": ax.get_ylim3d(), "z": ax.get_zlim3d()} spec = self._spec depth_column = next((c for c in (spec.x, spec.y, self._z_column) if c not in (first, second)), "") if not depth_column: return depth = limits[{spec.x: "x", spec.y: "y", self._z_column: "z"}[depth_column]] palette = active_palette() order = {spec.x: 0, spec.y: 1, self._z_column: 2} for far in depth: points = [] for px, py in ((x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)): point = [None, None, None] point[order[first]] = px point[order[second]] = py point[order[depth_column]] = far points.append(point) xs, ys, zs = zip(*points) self._ghost.extend(ax.plot(xs, ys, zs, color=palette["warning"], linewidth=1.2, linestyle="--")) self._canvas.draw_idle() def _gate_from_volume_drag(self, event) -> Optional[Gate]: """The box a drag on the snapped view describes. Bounded on the two measurements the user could actually see, and UNBOUNDED on the one pointing at them. That is the honest reading of the gesture: they said nothing about depth, so the gate says nothing about depth -- and a box with an unbounded axis is exactly a rectangle extended through the volume. """ corner = self.screen_to_volume(event) start = self._volume_drag if corner is None or start is None: return None first, x0, second, y0 = start _f, x1, _s, y1 = corner if x0 == x1 or y0 == y1: return None spec = self._spec depth_column = next((c for c in (spec.x, spec.y, self._z_column) if c not in (first, second)), "") if not depth_column: return None low, high = self.pending_depth() shape = self.volume_shape() if shape in ("oval", "circle"): u_radius, v_radius = abs(x1 - x0) / 2.0, abs(y1 - y0) / 2.0 if shape == "circle": u_radius = v_radius = max(u_radius, v_radius) return CylinderGate( name="(unnamed)", u_column=first, v_column=second, axis_column=depth_column, u_centre=(x0 + x1) / 2.0, v_centre=(y0 + y1) / 2.0, u_radius=u_radius, v_radius=v_radius, axis_low=low, axis_high=high) bounds = {first: (min(x0, x1), max(x0, x1)), second: (min(y0, y1), max(y0, y1))} def side(column): """The stored bounds for one column, or ``(None, None)``.""" return bounds.get(column, (None, None)) x_low, x_high = side(spec.x) y_low, y_high = side(spec.y) z_low, z_high = side(self._z_column) if low is not None or high is not None: bounds[depth_column] = (low, high) x_low, x_high = side(spec.x) y_low, y_high = side(spec.y) z_low, z_high = side(self._z_column) return BoxGate(name="(unnamed)", x_column=spec.x, y_column=spec.y, z_column=self._z_column, x_low=x_low, x_high=x_high, y_low=y_low, y_high=y_high, z_low=z_low, z_high=z_high) def _extend_lasso(self, event) -> None: """Add the pointer to the outline being drawn, and show it.""" if self._lasso is None: return point = (float(getattr(event, "x", 0) or 0), float(getattr(event, "y", 0) or 0)) last = self._lasso[-1] if self.volume_shape() in _RECT_SHAPES: self._lasso = [self._lasso[0], point] elif (point[0] - last[0]) ** 2 + (point[1] - last[1]) ** 2 >= 4.0: self._lasso.append(point) self._show_lasso_ghost() def _lasso_outline(self) -> List[Tuple[float, float]]: """The outline drawn so far, in canvas pixels, as a closed shape.""" points = list(self._lasso or ()) if self.volume_shape() in _RECT_SHAPES and len(points) >= 2: (x0, y0), (x1, y1) = points[0], points[-1] return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] return points def _show_lasso_ghost(self) -> None: """Draw the outline under the pointer, flat on the screen.""" from matplotlib.lines import Line2D from matplotlib.transforms import IdentityTransform self._clear_ghost() outline = self._lasso_outline() if len(outline) < 2: return xs = [p[0] for p in outline] + [outline[0][0]] ys = [p[1] for p in outline] + [outline[0][1]] line = Line2D(xs, ys, transform=IdentityTransform(), color=active_palette()["warning"], linewidth=1.2, linestyle="--") self._figure.add_artist(line) self._ghost.append(line) self._show_live(outline) self._canvas.draw_idle()
[docs] def view_projection(self, ax=None) -> Optional[np.ndarray]: """The camera now on screen, as a :class:`ViewGate` stores it. matplotlib's own projection matrix for the current angles and limits, rescaled so its homogeneous coordinate is positive in front of the camera. Taken fresh from ``get_proj`` rather than from the matrix of the last draw, so a gate drawn straight after a spin uses the angle the spin left. :returns: a 4 x 4 array, or None when the volume is not on screen. """ ax = ax if ax is not None else self.axes_at(0, 0) if ax is None or not hasattr(ax, "get_proj"): return None try: matrix = np.asarray(ax.get_proj(), dtype=float) limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) except Exception: LOG.debug("could not read the 3D camera", exc_info=True) return None if matrix.shape != (4, 4) or not np.isfinite(matrix).all(): return None centre = np.array([(lo + hi) / 2.0 for lo, hi in limits] + [1.0]) weight = float(matrix[3] @ centre) if weight == 0.0: return None return matrix / abs(weight) * (1.0 if weight > 0 else -1.0)
[docs] def gate_from_screen_outline(self, outline, *, name: str = "(unnamed)") -> Optional[Gate]: """A :class:`ViewGate` for an outline drawn on the screen. :param outline: the outline's vertices in canvas pixels, in the order drawn. :returns: the gate, or None when the outline has fewer than three distinct corners or no area, or the volume is not on screen. """ ax = self.axes_at(0, 0) spec = self._spec columns = (spec.x, spec.y, self._z_column) if ax is None or not all(columns) or len(set(columns)) != 3: return None matrix = self.view_projection(ax) if matrix is None: return None pixels = np.asarray(outline, dtype=float).reshape(-1, 2) if len(pixels) < 3: return None try: projected = ax.transData.inverted().transform(pixels) except Exception: LOG.debug("could not map the outline off the screen", exc_info=True) return None width = pixels[:, 0].max() - pixels[:, 0].min() height = pixels[:, 1].max() - pixels[:, 1].min() if width < 3.0 or height < 3.0: return None vertices = tuple((float(a), float(b)) for a, b in projected) limits = (tuple(ax.get_xlim3d()), tuple(ax.get_ylim3d()), tuple(ax.get_zlim3d())) view = (float(getattr(ax, "elev", 0.0) or 0.0), float(getattr(ax, "azim", 0.0) or 0.0), float(getattr(ax, "roll", 0.0) or 0.0)) try: return ViewGate(name=name, x_column=columns[0], y_column=columns[1], z_column=columns[2], projection=tuple(map(tuple, matrix)), vertices=vertices, view=view, limits=limits) except GateError as exc: self.depth_requested.emit(str(exc)) return None
def _draw_view_gate(self, ax, gate: ViewGate, colour) -> None: """Show a view gate's outline swept through the volume. The outline is placed at the nearest and farthest depths of the box and joined, so from the angle it was drawn at it reads as the shape the user drew, and from any other angle as the solid it cuts. """ matrix = np.asarray(gate.projection, dtype=float) try: limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) except Exception: return shown = (self._spec.x, self._spec.y, self._z_column) order = [shown.index(column) for column in gate.columns] ranges = [limits[i] for i in order] corners = np.array([[x, y, z, 1.0] for x in ranges[0] for y in ranges[1] for z in ranges[2]]) view = corners @ matrix.T with np.errstate(divide="ignore", invalid="ignore"): depth = view[:, 2] / view[:, 3] depth = depth[np.isfinite(depth)] if not len(depth): return rings = [] for target in (float(depth.min()), float(depth.max())): ring = [] for sx, sy in gate.vertices: system = np.vstack([matrix[0] - sx * matrix[3], matrix[1] - sy * matrix[3], matrix[2] - target * matrix[3]]) try: point = np.linalg.solve(system[:, :3], -system[:, 3]) except np.linalg.LinAlgError: return placed = np.empty(3) placed[order] = point ring.append(placed) rings.append(np.asarray(ring)) width = self._line_width + 0.6 try: for ring, alpha in zip(rings, (0.95, 0.45)): closed = np.vstack([ring, ring[:1]]) self._artists.extend(ax.plot( closed[:, 0], closed[:, 1], closed[:, 2], color=colour, linewidth=width, alpha=alpha)) step = max(1, len(gate.vertices) // 8) for index in range(0, len(gate.vertices), step): a, b = rings[0][index], rings[1][index] self._artists.extend(ax.plot( [a[0], b[0]], [a[1], b[1]], [a[2], b[2]], color=colour, linewidth=self._line_width, alpha=0.35)) finally: ax.set_xlim3d(*limits[0]) ax.set_ylim3d(*limits[1]) ax.set_zlim3d(*limits[2])
[docs] def set_pending_depth(self, low, high) -> None: """The slab depth the next volume gate is made with. The depth is represented as a slab you drag out over "all the way through": the depth is a second gesture after the shape is drawn, so the gate is finite from the start rather than something to narrow afterwards in a panel. ``(None, None)`` means full depth, which is what an undragged shape means and what the 2D gate on that plane already meant. :param low: lower bound along the plane's normal axis, in data units, or ``None`` for unbounded. :param high: upper bound likewise; the two are swapped if given in the wrong order. """ if low is not None and high is not None and low > high: low, high = high, low self._pending_depth = (low, high)
[docs] def pending_depth(self): """``(low, high)`` for the next volume gate.""" return getattr(self, "_pending_depth", (None, None))
def _begin_volume_depth(self, gate: Gate) -> None: """Hold a footprint until a second drag gives it depth. A click (no distance) on the second gesture means full depth. That keeps the unbounded gate available without making it the only thing a drawing can produce. """ plane = self.anchor_plane() if plane is None: self.gate_drawn.emit(gate) return self._pending_volume_gate = gate self._pending_volume_axis = plane[2] self.depth_requested.emit( f"Footprint ready on {plane[0]} / {plane[1]}. Drag once more " f"along {plane[2]} to set its depth; click for full depth.") def _depth_bounds_from_drag(self, start, event): """Convert the second gesture into bounds on the plane normal. The distance is projected onto the normal axis as it appears on the screen, then expressed as a fraction of that measurement's visible range. The gesture therefore remains meaningful after spinning and under unequal measurement units. """ axis_column = self._pending_volume_axis ax = self.axes_at(0, 0) spec = self._spec columns = (spec.x, spec.y, self._z_column) if (ax is None or not hasattr(ax, "get_zlim") or axis_column not in columns): return None limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) axis = columns.index(axis_column) low, high = map(float, limits[axis]) span = high - low if not np.isfinite(span) or span == 0: return None centre = [(float(a) + float(b)) / 2.0 for a, b in limits] first = list(centre) second = list(centre) first[axis], second[axis] = low, high try: p0 = np.asarray(ax.transData.transform(_project(ax, first)), float) p1 = np.asarray(ax.transData.transform(_project(ax, second)), float) except Exception: return None normal = p1 - p0 length2 = float(normal @ normal) if length2 < 1e-9: return None delta = np.asarray([ float(getattr(event, "x", 0) or 0) - float(start[0]), float(getattr(event, "y", 0) or 0) - float(start[1]), ]) if float(delta @ delta) < 9.0: return (None, None) fraction = min(1.0, abs(float(delta @ normal) / length2)) if fraction >= 0.98: return (None, None) return (low, low + span * fraction) def _finish_volume_depth(self, bounds) -> Optional[Gate]: """Turn a drawn face plus a depth into a solid gate. THE DEPTH IS A SECOND GESTURE. A volume cannot be drawn in one drag on a 2-D screen, so the face is swept first and the height asked for afterwards -- which is why this is separate from the release handler. :param bounds: the depth extent chosen. :returns: the finished gate, or None if it was abandoned. """ gate = self._pending_volume_gate axis = self._pending_volume_axis if gate is None or not axis: return None low, high = bounds try: gate = gate.with_threshold(axis, low, high) except GateError as exc: self.depth_requested.emit(str(exc)) return None self._pending_volume_gate = None self._pending_volume_axis = "" self.set_pending_depth(None, None) self.depth_requested.emit("") self.gate_drawn.emit(gate) return gate
[docs] def set_anchor_axis(self, axis: str) -> None: """Pick which plane a drag draws on. Chosen, never inferred. The first version of this read the plane off the camera and returned nothing unless the view was square-on, so turning the volume silently changed what the next gate would mean. The user picks a plane and it stays picked. :param axis: ``"x"``, ``"y"`` or ``"z"``, the normal of the plane drawn on; anything else is taken as ``"z"``. """ self._anchor_axis = axis if axis in ("x", "y", "z") else "z" self._draw_gates()
[docs] def anchor_axis(self) -> str: """Which world axis a 3-D drag rotates about. :returns: the axis name, defaulting to ``z``. """ return getattr(self, "_anchor_axis", "z")
[docs] def set_drag_mode(self, mode: str) -> None: """``'spin'`` or ``'draw'``. They were competing for one button. :param mode: ``"spin"`` or ``"draw"``; anything else is taken as ``"spin"``. """ self._drag_mode = mode if mode in ("spin", "draw") else "spin"
[docs] def drag_mode(self) -> str: """What dragging does right now: spin the view, or draw. :returns: the mode's name, defaulting to ``spin``. """ return getattr(self, "_drag_mode", "spin")
[docs] def set_volume_shape(self, shape: str) -> None: """Which of :data:`VOLUME_SHAPES` a drag draws. :param shape: a shape key such as ``"box"``, ``"lasso"`` or ``"polygon"``; empty is taken as ``"box"``. """ self._volume_shape = str(shape or "box")
[docs] def volume_shape(self) -> str: """Which solid a 3-D gate is drawn as. :returns: the shape's name, defaulting to ``box``. """ return getattr(self, "_volume_shape", "box")
[docs] def anchor_plane(self) -> Optional[Tuple[str, str, str]]: """``(first, second, normal)`` of the plane a drag draws on. READ FROM THE PICKED AXIS, not from the camera. Three planes are visible in the volume; :meth:`set_anchor_axis` says which one is armed, and turning the view does not change it. The normal is the picked axis, and the other two are the plane -- so picking Z means "draw on X/Y and extend along Z", which is what the axis labels on the plot already say. :returns: None only in 2D, where there is no third measurement for a shape to be extended along. """ if self._mode not in ("3D", "xD"): return None spec = self._spec columns = {"x": spec.x, "y": spec.y, "z": self._z_column} if not all(columns.values()): return None if len(set(columns.values())) != 3: return None normal_axis = self.anchor_axis() normal = columns[normal_axis] plane = [columns[a] for a in ("x", "y", "z") if a != normal_axis] return (plane[0], plane[1], normal)
def _draw_anchor_aura(self, ax) -> None: """The blue hue on the plane the next shape would land on. A translucent FILLED quad rather than an edge highlight, because point 1 asks for it to be visible from any camera angle and an edge disappears the moment it points at the viewer. """ plane = self.anchor_plane() if plane is None or self.volume_shape() in VIEW_SHAPES + _FITTED_SHAPES + ( "view_polygon",): return first, second, normal = plane spec = self._spec axis_of = {spec.x: "x", spec.y: "y", self._z_column: "z"} limits = {"x": ax.get_xlim3d(), "y": ax.get_ylim3d(), "z": ax.get_zlim3d()} u0, u1 = limits[axis_of[first]] v0, v1 = limits[axis_of[second]] far = limits[axis_of[normal]][0] order = {spec.x: 0, spec.y: 1, self._z_column: 2} corners = [] for pu, pv in ((u0, v0), (u1, v0), (u1, v1), (u0, v1)): point = [None, None, None] point[order[first]] = pu point[order[second]] = pv point[order[normal]] = far corners.append(point) try: from mpl_toolkits.mplot3d.art3d import Poly3DCollection quad = Poly3DCollection( [corners], facecolor=active_palette()["accent"], alpha=0.12, edgecolor=active_palette()["accent"], linewidths=0.8) ax.add_collection3d(quad) self._artists.append(quad) except Exception: LOG.debug("could not draw the anchor plane", exc_info=True) def _apply_volume_zoom(self, ax) -> None: """Scale the three axes about the data's centre. The limits, not the camera: a gate is a statement in data units, and a camera trick would leave the outlines somewhere other than the objects they enclose. """ spec = self._spec frame = self.population() if frame is None: return factor = 1.0 / float(self._volume_zoom) for column, setter in ((spec.x, ax.set_xlim3d), (spec.y, ax.set_ylim3d), (self._z_column, ax.set_zlim3d)): if not column or column not in frame.columns: continue values = pd.to_numeric(frame[column], errors="coerce").to_numpy(float) values = values[np.isfinite(values)] if not len(values): continue centre = float(values.mean()) half = max(float(values.std()) * 3.0, (float(values.max()) - float(values.min())) / 2.0) or 1.0 setter(centre - half * factor, centre + half * factor) def _on_press(self, event) -> None: """Begin drawing a gate, or pass the press to the view. :param event: the matplotlib press event. """ if (self._in_volume() and self.drag_mode() == "draw" and not _is_right_button(event) and self.volume_shape() == "polygon"): placed = self.screen_to_volume(event) if placed is None: return first, x, second, y = placed plane = (first, second) if self._pending and self._pending_plane != plane: self._pending = [] self._pending_plane = plane if (len(self._pending) >= 3 and self._near_first_volume_vertex(event)): self.close_polygon_now() return self._pending.append((x, y)) self.polygon_changed.emit(len(self._pending)) self._draw_gates() return if self._volume_press(event): return mid_polygon = self._tool == POLYGON and bool(self._pending) if not mid_polygon: grabbed = self.handle_at(event) if grabbed is not None: self._resize = grabbed return if not mid_polygon and event.inaxes is not None \ and event.xdata is not None and event.ydata is not None: name = self.gate_at(float(event.xdata), float(event.ydata)) if name: self._move_name = name self._move_from = (float(event.xdata), float(event.ydata)) return if self._tool == WAND: self._wand_at(event) return if self._tool != POLYGON: super()._on_press(event) return if event.inaxes is None or event.xdata is None or event.ydata is None: return x, y = float(event.xdata), float(event.ydata) if len(self._pending) >= 3 and self._near_first_vertex(event, x, y): self.close_polygon_now() return self._pending.append((x, y)) self.polygon_changed.emit(len(self._pending)) self._draw_gates() def _wand_at(self, event) -> None: """Grow a gate from a click and offer it like any other drawn gate. The wand emits `gate_drawn`, so it lands in the same naming and undo path as a dragged shape -- it is a way of PRODUCING a polygon, not a fourth kind of gate. A click that cannot grow one reports why in the status line rather than raising: the two things that make it fail, clicking in empty space and a tolerance too small for this cloud, are both things the user fixes by clicking again. """ from .gate_spec import WandError, wand_gate spec = self._spec if event.inaxes is None or event.xdata is None or event.ydata is None: return frame = self.population() if frame is None or frame.empty or not (spec.x and spec.y): self.wand_failed.emit( "the wand needs a table and two measurements on screen") return settings = self._settings try: gate = wand_gate( frame, spec.x, spec.y, float(event.xdata), float(event.ydata), tolerance=float(getattr(settings, "wand_tolerance", 0.05)), max_radius=float(getattr(settings, "wand_max_radius", 0.35))) except WandError as exc: self.wand_failed.emit(str(exc)) return except GateError as exc: self.wand_failed.emit(str(exc)) return self.gate_drawn.emit(gate) def _near_first_vertex(self, event, x: float, y: float) -> bool: """Whether ``(x, y)`` is close enough to the first vertex to close. Measured in PIXELS, not data units: "close enough to click" is a property of the screen, and a data-unit tolerance would be unusable on one axis and impossible on the other whenever the two measurements have different ranges -- which is nearly always. """ if not self._pending: return False ax = getattr(event, "inaxes", None) first = self._pending[0] try: fx, fy = ax.transData.transform(first) px, py = ax.transData.transform((x, y)) except Exception: return False return ((fx - px) ** 2 + (fy - py) ** 2) ** 0.5 <= self.CLOSE_RADIUS_PX def _near_first_volume_vertex(self, event) -> bool: """Whether a volume click closes the polygon under the cursor. ``Axes3D.transData`` accepts projected 2D coordinates, not the two measurement values stored in ``_pending``. Feeding those values to the 2D helper made click-the-first-vertex work only by coincidence. Project the real 3D point on the selected face before measuring the pixel distance. """ if not self._pending or not self._pending_plane: return False ax = getattr(event, "inaxes", None) if ax is None: return False point = self._volume_face_point(ax, *self._pending[0]) if point is None: return False try: fx, fy = ax.transData.transform(_project(ax, point)) px = float(getattr(event, "x", 0) or 0) py = float(getattr(event, "y", 0) or 0) except Exception: return False return ((fx - px) ** 2 + (fy - py) ** 2) ** 0.5 \ <= self.CLOSE_RADIUS_PX
[docs] def close_polygon_now(self) -> None: """Close the pending polygon and emit at most one completed gate. The first-vertex shortcut and the Close button both use this method. """ if self._mode in ("3D", "xD") and len(self._view_polygon) >= 3: self._finish_view_polygon() return if self._mode in ("3D", "xD") and self._pending_plane: gate = self.close_polygon(emit=False) if gate is not None: self._begin_volume_depth(gate) return self.close_polygon()
def _on_scroll(self, event) -> None: """Zoom about the pointer with the wheel. About the POINTER rather than the centre: zooming toward what you are looking at is what every map does, and centre-zoom means chasing a feature back into view after every notch. Data limits, not a transform, so the gates -- which are drawn in data coordinates -- stay exactly where they belong on the measurements. """ if self._volume_scroll(event): return ax = getattr(event, "inaxes", None) if ax is None or event.xdata is None or event.ydata is None: return step = getattr(event, "step", 0) or ( 1 if getattr(event, "button", "") == "up" else -1) factor = 0.8 ** float(step) def zoomed(limits, anchor): """Scale one axis's limits about the anchor the pointer is over. Anchored on the POINTER rather than the centre, so the point under the cursor stays put -- which is what makes a scroll feel like zooming in on something rather than the plot sliding away. """ low, high = limits return (anchor + (low - anchor) * factor, anchor + (high - anchor) * factor) self._zoom = (zoomed(ax.get_xlim(), float(event.xdata)), zoomed(ax.get_ylim(), float(event.ydata))) self.render_now()
[docs] def reset_view(self) -> None: """Back to the limits the data asks for, and the starting angle.""" self._zoom = None self._volume_zoom = 1.0 self._view_angles = None self._spin_from = None self.render_now()
#: Kept as the old name: `reset_zoom` was the 2D-only version. reset_zoom = reset_view def _apply_scales(self, ax, kind, scales, panel) -> None: """Let a wheel zoom outlive the redraw that follows it. The computed scales are applied AFTER the marks are drawn, so limits set by the wheel alone are undone by the next render -- and a render happens on every gate edit. Re-applying here is what makes the zoom a state of the view rather than a gesture that survives until the next click. """ super()._apply_scales(ax, kind, scales, panel) if self._zoom is None: return (x_limits, y_limits) = self._zoom ax.set_xlim(*x_limits) ax.set_ylim(*y_limits) def _on_motion(self, event) -> None: """Grow the gate being drawn. :param event: the matplotlib motion event. """ if self._volume_motion(event): return if self._resize is not None or getattr(self, "_move_name", None): self._show_ghost(self._dragged_to(event)) return if self._tool == POLYGON: return super()._on_motion(event) def _on_release(self, event) -> None: """Finish the gate and hand it to the panel. :param event: the matplotlib release event. """ if self._volume_release(event): return if self._resize is not None: name, _role = self._resize edited = self._dragged_to(event) self._resize = None self._clear_ghost() if edited is None: self.render_now() return self.gate_edited.emit(edited) return name = getattr(self, "_move_name", None) if name: start = getattr(self, "_move_from", None) self._move_name = None self._move_from = None self._clear_ghost() if (start is None or event.inaxes is None or event.xdata is None or event.ydata is None): return dx = float(event.xdata) - start[0] dy = float(event.ydata) - start[1] if dx == 0 and dy == 0: self.set_gates(self.gates, active=name) return try: gate = self.gates.get(name) except Exception: return self.gate_edited.emit(gate.translated(dx, dy)) return if self._tool in ("", POLYGON): super()._on_release(event) return origin, self._drag_origin = self._drag_origin, None if self._drag_patch is not None: try: self._drag_patch.remove() except (ValueError, NotImplementedError): pass self._drag_patch = None if origin is None or event.inaxes is not origin[0]: return if event.xdata is None or event.ydata is None: return _ax, x0, y0 = origin gate = self.gate_from_drag(x0, y0, float(event.xdata), float(event.ydata)) if gate is not None: self.gate_drawn.emit(gate) def _make_drag_patch(self, x0: float, y0: float): """Create a drag preview matching the armed gate shape.""" if self._tool == ELLIPSE: from matplotlib.patches import Ellipse return Ellipse((x0, y0), 0.0, 0.0, **self._drag_patch_style()) return super()._make_drag_patch(x0, y0) def _update_drag_patch(self, patch, x0: float, y0: float, x1: float, y1: float) -> None: """Redraw the in-progress shape for the current drag box. AN ELLIPSE IS INSCRIBED IN THE SWEPT BOX, which is what makes the drag mean the same thing for every tool: the user sweeps a rectangle and the tool decides what fits inside it. :param patch: the artist being updated. :param x0: the drag's start x. :param y0: its start y. :param x1: its current x. :param y1: its current y. """ if self._tool == ELLIPSE: patch.set_center(((x0 + x1) / 2.0, (y0 + y1) / 2.0)) patch.set_width(abs(x1 - x0)) patch.set_height(abs(y1 - y0)) return super()._update_drag_patch(patch, x0, y0, x1, y1)
[docs] def gate_from_drag(self, x0: float, y0: float, x1: float, y1: float, *, name: str = "(unnamed)") -> Optional[Gate]: """Build the armed tool's gate from a swept rectangle. Public so the interaction can be driven without synthesising mouse events — the same seam the Graph Builder's :meth:`brush` provides. :param x0: x of the drag's start, in data units; the low bound of a threshold gate. :param y0: y of the drag's start, in data units. :param x1: x of the drag's end; the high bound of a threshold gate. :param y1: y of the drag's end. :returns: a threshold, rectangle or ellipse gate for the armed tool, or ``None`` for any other tool, missing columns, or a degenerate ellipse. """ spec = self._spec if self._tool == THRESHOLD: column = spec.x or spec.y if not column: return None return ThresholdGate(name=name, column=column, low=x0, high=x1) if self._tool == RECTANGLE: if not (spec.x and spec.y): return None return RectGate(name=name, x_column=spec.x, y_column=spec.y, x_low=x0, x_high=x1, y_low=y0, y_high=y1) if self._tool == ELLIPSE: if not (spec.x and spec.y): return None if x0 == x1 or y0 == y1: return None return EllipseGate.from_drag(name, spec.x, spec.y, x0, y0, x1, y1) return None
[docs] def close_polygon(self, *, name: str = "(unnamed)", emit: bool = True) -> Optional[Gate]: """Finish the polygon being clicked out. :returns: the gate, or ``None`` when fewer than three vertices have been clicked — the canvas does not raise at the user for clicking twice and changing their mind. """ spec = self._spec if len(self._pending) < 3 or not (spec.x and spec.y): return None if self._mode in ("3D", "xD") and self._pending_plane: depth_low, depth_high = self.pending_depth() first, second = self._pending_plane normal = next((c for c in (spec.x, spec.y, self._z_column) if c not in (first, second)), "") if not normal: return None gate = PrismGate(name=name, u_column=first, v_column=second, axis_column=normal, vertices=tuple(self._pending), axis_low=depth_low, axis_high=depth_high) self._pending = [] self._pending_plane = None self.polygon_changed.emit(0) if emit: self.gate_drawn.emit(gate) return gate gate = PolygonGate(name=name, x_column=spec.x, y_column=spec.y, vertices=tuple(self._pending)) self._pending = [] self.polygon_changed.emit(0) if emit: self.gate_drawn.emit(gate) return gate
[docs] class GateTree(QWidget): """The gating hierarchy, with each gate's n and its percentage of parent. Both percentages are shown, from :class:`~spacr.qt.widgets.gate_spec.GateStats` — 90% of a parent that is 2% of the table is 1.8% of the objects, and a strategy that prints only the first is flattering itself. :param parent: parent widget. """ #: The selected gate changed — carries the name, or ``""`` for the root. active_changed = Signal(str) #: A gate was deleted. gates_changed = Signal() #: A gate was ticked or unticked — carries the name and whether it is on. enabled_changed = Signal(str, bool) def __init__(self, parent=None): """Build the hierarchy view. :param parent: parent widget. """ super().__init__(parent) self.setObjectName("GateTree") self._gates = GateSet() self._frame: Optional[pd.DataFrame] = None self._colour_source = None outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) self.tree = QTreeWidget(self) install_sorting(self.tree) self.tree.setObjectName("GateHierarchy") self.tree.setColumnCount(4) self.tree.setHeaderLabels(["Gate", "n", "% parent", "% all"]) header = self.tree.header() header.setSectionResizeMode(0, QHeaderView.Stretch) for column in range(1, 4): header.setSectionResizeMode(column, QHeaderView.ResizeToContents) header.setStretchLastSection(False) header.setMinimumSectionSize(44) self.tree.setToolTip( "The gates you have drawn. Tick one to show it on the plot and " "highlight its objects, untick it to hide it. Selecting a gate " "sets the axes to its measurements and makes the next gate you " "draw a child of it — it never changes what the plot shows.") self.tree.currentItemChanged.connect(self._on_selection) self.tree.itemDoubleClicked.connect(self._on_double_clicked) self.active_changed.connect(self._rebuild_thresholds) self.tree.itemChanged.connect(self._on_item_changed) #: Gates the user has unticked. The tree owns this because the tick #: is in the tree; the canvas is told, and does not have to be asked. self._disabled: set = set() #: Set while `refresh` is rebuilding, because setting a check state #: fires `itemChanged` and a rebuild would otherwise report every #: gate as freshly toggled by the user. self._rebuilding = False mark_surface(self.tree) outer.addWidget(self.tree, 1) self._thresholds = QWidget(self) self._threshold_form = QFormLayout(self._thresholds) self._threshold_form.setContentsMargins(0, 0, 0, 0) self._threshold_rows: Dict[str, Tuple[QLineEdit, QLineEdit]] = {} #: Which gate the rows above belong to. Remembered rather than #: re-read from the selection: if the selection moves between a row #: being filled in and the edit landing, re-reading would put the #: number on the wrong gate. self._threshold_gate: str = "" self._thresholds.setVisible(False) outer.addWidget(self._thresholds) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) self._remove = QPushButton("Delete gate", self) self._remove.setToolTip( "Deletes the gate and everything gated inside it — a child whose " "parent is gone is a gate on a population that no longer exists.") self._remove.clicked.connect(self.remove_selected) row.addWidget(self._remove) row.addStretch(1) outer.addLayout(row)
[docs] def set_gates(self, gates: GateSet, frame: Optional[pd.DataFrame]) -> None: """Show a gate hierarchy, counted against a table. BOTH ARGUMENTS TOGETHER. The counts and percentages are a property of the gates AND the rows they were applied to, so a tree given new gates against the old frame would show numbers belonging to neither. :param gates: the hierarchy to show. :param frame: the rows to count against, or None for no counts. """ self._gates = gates self._frame = frame self.refresh()
[docs] def refresh(self) -> None: """Rebuild the tree and recompute every count.""" current = self.active_gate() self._rebuilding = True try: self._rebuild(current) finally: self._rebuilding = False
#: What the count column reads for a gate this table cannot answer. UNAVAILABLE = "n/a" def _gate_stats(self): """``(stats by gate name, reason by gate name)`` for this frame. :meth:`GateSet.stats` is all-or-nothing: it walks every gate and the first one whose columns are absent raises, taking the counts of every OTHER gate with it. Dropping the nucleus table from the working set must cost the nucleus gates their numbers and nothing else, so the fallback here evaluates gate by gate and keeps the reason for each one it could not. """ try: return {s.name: s for s in self._gates.stats(self._frame)}, {} except GateError as exc: LOG.info("some gates do not apply to this table: %s", exc) from .gate_spec import GateStats stats: Dict[str, Any] = {} why: Dict[str, str] = {} total = int(len(self._frame)) counts: Dict[str, int] = {} for gate in self._gates.order(): try: n_in = int(self._gates.mask(self._frame, gate.name).sum()) except Exception as gate_exc: why[gate.name] = str(gate_exc) continue counts[gate.name] = n_in n_parent = (counts.get(gate.parent, total) if gate.parent else total) stats[gate.name] = GateStats( name=gate.name, depth=self._gates.depth(gate.name), n_total=total, n_parent=n_parent, n_in=n_in) return stats, why def _rebuild(self, current: str) -> None: """Rebuild every row from the gates and their counts.""" self.tree.clear() if self._frame is None: return stats, unavailable = self._gate_stats() items: Dict[str, QTreeWidgetItem] = {} for gate in self._gates.order(): stat = stats.get(gate.name) labels = [gate.name, "", "", ""] if stat is not None: labels = [gate.name, f"{stat.n_in:,}", f"{100.0 * stat.of_parent:.1f}%", f"{100.0 * stat.of_total:.1f}%"] elif gate.name in unavailable: labels = [gate.name, self.UNAVAILABLE, "", ""] item = tree_item(labels) item.setData(0, Qt.UserRole, gate.name) item.setCheckState(0, Qt.Unchecked if gate.name in self._disabled else Qt.Checked) colour = self._colour_for(gate.name) if colour: item.setForeground(0, QBrush(QColor(colour))) reason = unavailable.get(gate.name) if reason: item.setToolTip(0, f"{gate.describe()}\n\nNot applicable to " f"the tables in the working set: {reason}") item.setToolTip(1, reason) else: item.setToolTip(0, gate.describe()) parent_item = items.get(gate.parent) if gate.parent else None if parent_item is None: self.tree.addTopLevelItem(item) else: parent_item.addChild(item) items[gate.name] = item self.tree.expandAll() if current in items: self.tree.setCurrentItem(items[current]) def _colour_for(self, name: str) -> str: """The gate's colour, asked of whoever is drawing it. The canvas owns the mapping so the two cannot disagree; the tree only displays it. Returns "" when there is no canvas -- the tree is usable on its own, and a missing colour is not worth failing over. """ source = getattr(self, "_colour_source", None) if source is None: return "" try: return str(source(name) or "") except Exception: return ""
[docs] def set_colour_source(self, source) -> None: """Tell the tree where gate colours come from -- see `_colour_for`. :param source: callable taking a gate name and returning a colour string, normally :meth:`GateCanvas.gate_colour`, or ``None``. """ self._colour_source = source self.refresh()
def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None: """A tick changed — unless the tree is rebuilding itself.""" if self._rebuilding or column != 0 or item is None: return name = item.data(0, Qt.UserRole) if not name: return on = item.checkState(0) == Qt.Checked if on: self._disabled.discard(name) else: self._disabled.add(name) self.enabled_changed.emit(name, on)
[docs] def is_enabled(self, name: str) -> bool: """Whether ``name`` is ticked. Unknown gates are on. :param name: the gate's name. """ return name not in self._disabled
[docs] def active_gate(self) -> str: """The name of the selected gate. :returns: the gate's name, or ``""`` when nothing is selected. """ item = self.tree.currentItem() return item.data(0, Qt.UserRole) if item is not None else ""
[docs] def select(self, name: str) -> None: """Select a gate by name, wherever it sits in the hierarchy. :param name: the gate's name. """ for index in range(self.tree.topLevelItemCount()): if self._select_in(self.tree.topLevelItem(index), name): return self.tree.setCurrentItem(None)
def _select_in(self, item: QTreeWidgetItem, name: str) -> bool: """Find and select a gate under one row, recursing into its children. :param item: the row to search under. :param name: the gate's name. :returns: True when it was found and selected. """ if item.data(0, Qt.UserRole) == name: self.tree.setCurrentItem(item) return True return any(self._select_in(item.child(i), name) for i in range(item.childCount())) def _on_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: """Ask for a new name for the gate whose name was double-clicked.""" if column != 0 or item is None: return old = str(item.data(0, Qt.UserRole) or item.text(0)).strip() if old not in self._gates.names: return name, ok = QInputDialog.getText( self, tr("Rename gate"), tr("New name for the gate:"), text=old) if ok: self._rename_gate(old, name) def _rename_gate(self, old: str, new: str) -> bool: """Rename a gate, keeping its children and combinations pointing at it. :param old: the gate's current name. :param new: the name it should have; surrounding spaces are dropped. :returns: True when the gate was renamed, False when ``old`` does not exist or ``new`` is empty or already taken. """ new = str(new or "").strip() names = self._gates.names if old not in names or not new or new == old or new in names: return False renamed = [] for gate in self._gates.gates: if gate.name == old: gate = gate.rename(new) if gate.parent == old: gate = gate.with_parent(new) if isinstance(gate, CompositeGate) and old in gate.operands: gate = replace(gate, operands=tuple( new if o == old else o for o in gate.operands)) renamed.append(gate) self._gates.gates = renamed if old in self._disabled: self._disabled.discard(old) self._disabled.add(new) self.refresh() self.select(new) self.gates_changed.emit() self.active_changed.emit(self.active_gate()) return True
[docs] def remove_selected(self) -> None: """Delete the selected gate, and everything drawn inside it.""" name = self.active_gate() if not name: return self._gates.remove(name) self.refresh() self.gates_changed.emit() self.active_changed.emit(self.active_gate())
def _rebuild_thresholds(self, name: str) -> None: """Show one low/high pair per measurement the selected gate can bound. Blank means UNBOUNDED, not zero. That distinction is the whole interface here: a cylinder with no bound on its normal means the 2D oval extended through the volume, and a cylinder bounded 0..0 means nothing at all. """ while self._threshold_form.rowCount(): self._threshold_form.removeRow(0) self._threshold_rows = {} self._threshold_gate = str(name or "") gate = None if name and name in self._gates: gate = self._gates.get(name) offered = {} if gate is not None: try: offered = gate.thresholds() except Exception: LOG.debug("could not read thresholds", exc_info=True) self._thresholds.setVisible(bool(offered)) for column, (low, high) in offered.items(): pair = QWidget(self._thresholds) line = QHBoxLayout(pair) line.setContentsMargins(0, 0, 0, 0) low_edit, high_edit = QLineEdit(pair), QLineEdit(pair) for edit, value, hint in ((low_edit, low, "min"), (high_edit, high, "max")): edit.setText("" if value is None else f"{float(value):g}") edit.setPlaceholderText(hint) edit.setToolTip( f"Threshold on {column} for this gate. Leave it EMPTY " f"for no bound \u2014 empty is unbounded, not zero.") edit.editingFinished.connect( lambda c=column: self._apply_threshold(c)) line.addWidget(edit) self._threshold_rows[column] = (low_edit, high_edit) self._threshold_form.addRow(column, pair) def _apply_threshold(self, column: str) -> None: """Put an edited pair back on the gate.""" name = self._threshold_gate if not name or name not in self._gates: return low_edit, high_edit = self._threshold_rows.get(column, (None, None)) if low_edit is None: return def value(edit): """One threshold field as a number, or ``None`` when blank.""" text = edit.text().strip() if not text: return None try: return float(text) except ValueError: return None try: updated = self._gates.get(name).with_threshold( column, value(low_edit), value(high_edit)) except GateError: LOG.debug("gate %s cannot take a threshold on %s", name, column) return self._gates.add(updated) self._rebuild_thresholds(name) self.gates_changed.emit() def _on_selection(self, *_args) -> None: """Tell the panel which gate the tree now has selected.""" self.active_changed.emit(self.active_gate())
@dataclass(frozen=True) class _ClusterRun: """One clustering pass's parameters, from wherever they came from. The dialog and the Search tab are two editors of the same five numbers. Naming them once here is what lets `run_cluster` have ONE body -- and the modal's own docstring records what happens when two editors of the same settings drift: it opened on hardcoded 0.30/10 while Gate Settings offered 0.5/20, and the values the user set were discarded. """ eps: float min_samples: int scale: bool walk: bool walk_steps: int method: str class _ClusterSettingsDialog(QDialog): """DBSCAN's two parameters, with what they mean in the units they act in. `eps` is in SCALED units while scaling is on, which is what makes one default work across measurements whose ranges differ by orders of magnitude -- `cell_area` runs to thousands and `eccentricity` to one, and unscaled DBSCAN on that pair clusters on area alone. The checkbox says so rather than leaving the user to discover it by getting one blob. SEEDED FROM THE SAVED GATE SETTINGS, which it did not used to be. Gate Settings has offered `cluster_eps`, `cluster_min_samples` and `cluster_scale` for as long as this dialog has existed, and this dialog opened on its own hardcoded 0.30/10 regardless -- so values the user set deliberately were discarded, and the two disagreed about the defaults as well (0.5 and 20 against 0.30 and 10). `settings` is optional only because the dialog is constructible before `apply_settings` has run. :param parent: parent widget; ownership only. :param settings: the screen's settings, read for the user's own ``cluster_scale`` and friends. ``None`` falls back to the built-in defaults and is the reason above -- NOT an invitation to omit it: a dialog opened without settings is the bug this paragraph describes, silently discarding values the user set deliberately. """ def __init__(self, parent=None, settings=None): """Build the form, seeded from the screen's settings when given.""" super().__init__(parent) try: from ..dialogs import detach_from_window_manager detach_from_window_manager(self) except Exception: pass self.setWindowTitle("Cluster settings") form = QFormLayout(self) from .gate_settings import GateEditorSettings fallback = GateEditorSettings() source = settings if settings is not None else fallback def _setting(name): """One setting from the source, falling back per NAME rather than wholesale. A source that carries some settings and not others is the ordinary case, and taking the fallback object entire would discard the ones it did carry. """ value = getattr(source, name, None) return getattr(fallback, name) if value is None else value self._eps = QDoubleSpinBox(self) self._eps.setRange(0.01, 100.0) self._eps.setSingleStep(0.05) self._eps.setDecimals(2) self._eps.setValue(float(_setting("cluster_eps"))) self._eps.setToolTip( "Neighbourhood radius. Larger merges nearby populations into " "one; smaller splits one into several.") form.addRow("eps (radius)", self._eps) self._min_samples = QSpinBox(self) self._min_samples.setRange(2, 10000) self._min_samples.setValue(int(_setting("cluster_min_samples"))) self._min_samples.setToolTip( "Objects needed to seed a population. Anything sparser is " "treated as debris and left out of every gate.") form.addRow("min samples", self._min_samples) self._scale = Toggle("Standardise both axes first", self) self._scale.setChecked(bool(_setting("cluster_scale"))) self._scale.setToolTip( "On unless you know otherwise. Without it, the axis with the " "larger numeric range decides the clustering on its own.") form.addRow("", self._scale) self._walk = Toggle("Walk eps and use the best radius", self) self._walk.setChecked(bool(_setting("cluster_walk"))) self._walk.setToolTip( "Try a range of radii around the one above, score each by how " "well separated the populations are, and cluster at the best " "one. Use it when you do not know what eps should be.") form.addRow("", self._walk) self._walk_steps = QSpinBox(self) self._walk_steps.setRange(2, 200) self._walk_steps.setValue(int(_setting("cluster_walk_steps"))) self._walk_steps.setToolTip( "How many radii to try. Each one is a full DBSCAN pass, so this " "is what the search costs.") self._walk_steps.setEnabled(self._walk.isChecked()) self._walk.toggled.connect(self._walk_steps.setEnabled) form.addRow("walk steps", self._walk_steps) #: Not offered again here -- the algorithm is a Gate Settings #: decision, and this dialog is the per-run tuning of it. Carried so #: the run uses the method that was chosen, which is the whole #: defect: the picker existed and `cluster_gates` ran DBSCAN anyway. self._method = str(_setting("cluster_method")) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) form.addRow(buttons) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self) def eps(self) -> float: """The neighbour distance, in scaled units while scaling is on.""" return float(self._eps.value()) def min_samples(self) -> int: """How many neighbours an object needs before it can seed a cluster.""" return int(self._min_samples.value()) def scale(self) -> bool: """Whether the measurements are standardised before clustering.""" return bool(self._scale.isChecked()) def walk(self) -> bool: """Whether to search the parameter space rather than use the two numbers.""" return bool(self._walk.isChecked()) def walk_steps(self) -> int: """How many parameter combinations the walk tries.""" return int(self._walk_steps.value()) def method(self) -> str: """The clustering method chosen.""" return self._method
[docs] class GateEditorPanel(QWidget): """Canvas, tools and hierarchy: the whole gating surface. :meth:`publish` is the point of the screen — it turns the selected gate into a :class:`~spacr.selection.DataFilter` clause and pushes it onto the shared filter, so every open view narrows to the gated population. :param parent: parent widget. :param link: the :class:`~spacr.qt.linked_selection.LinkedSelection` this view joins, so selecting here selects in every other view on it. ``None`` joins the shared one; pass a private one in a test so the selection does not reach the rest of the application. :param source: this view's name on that link, stamped onto everything it publishes -- which is how a view knows not to answer its own selection. """ gates_changed = Signal() #: Selecting a gate asks the screen to show the measurements it was drawn #: on. Carries ``(x_column, y_column)``; y is empty for a one-column gate. axes_requested = Signal(str, str) #: The Settings button was pressed. The panel does not own the settings #: window -- the screen does, because sampling is the screen's job. settings_requested = Signal() #: The xD projection was switched on or off. Carries a bool. projection_requested = Signal(bool) #: A gating mode was chosen: "2D" or "3D". mode_requested = Signal(str) #: The volume's spin axis changed: "x", "y" or "z". spin_axis_changed = Signal(str) def __init__(self, parent=None, *, link=None, source: str = "gate_editor"): """Build the gating surface: canvas, tools and hierarchy. :param parent: parent widget. """ super().__init__(parent) self.setObjectName("GateEditorPanel") self._gates = GateSet() self._frame: Optional[pd.DataFrame] = None self._namer = None #: The gate-editor settings, kept because the CLUSTER button needs #: them and the canvas only takes the drawing ones. None until #: `apply_settings` runs, which is why every read below falls back to #: the dataclass default rather than assuming this is set. self._settings = None outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) tools = QHBoxLayout() tools.setContentsMargins(0, 0, 0, 0) tools.setSpacing(SPACING["xs"]) self._tool = QComboBox(self) self._tool.setObjectName("GateToolPicker") for key in ("",) + GATE_KINDS: if key in (BOX, CYLINDER, PRISM, VIEW_LASSO, COMPOSITE): continue self._tool.addItem(TOOL_LABELS[key].split(" — ")[0], key) self._tool.setToolTip("\n".join(TOOL_LABELS.values())) index = self._tool.findData(DEFAULT_TOOL) if index >= 0: self._tool.setCurrentIndex(index) self._tool.currentIndexChanged.connect(self._on_tool_changed) tools.addWidget(QLabel("Tool", self)) tools.addWidget(self._tool) self._settings_button = QPushButton("Settings", self) self._settings_button.setObjectName("GateSettingsButton") self._settings_button.setToolTip("Gate editor settings") self._settings_button.clicked.connect(self.settings_requested.emit) fit_to_text(self._settings_button) tools.addWidget(self._settings_button) self._reset_view = QPushButton("Reset view", self) self._reset_view.setToolTip( "Back to the limits the data asks for, after zooming or spinning " "too far. Gates are untouched — this moves the view, never them.") self._reset_view.clicked.connect(self.reset_view) fit_to_text(self._reset_view) tools.addWidget(self._reset_view) self._cluster = QPushButton("Cluster…", self) self._cluster.setToolTip( "Find dense populations with DBSCAN and turn each one into a " "gate you can edit, nest and save like any other.") self._cluster.clicked.connect(self._on_cluster) fit_to_text(self._cluster) tools.addWidget(self._cluster) self._mode_buttons: Dict[str, QPushButton] = {} group = QButtonGroup(self) group.setExclusive(True) for mode in ("2D", "3D"): button = QPushButton(mode, self) button.setCheckable(True) button.setChecked(mode == "2D") fit_to_text(button, padding=18) button.clicked.connect( lambda _checked=False, m=mode: self.mode_requested.emit(m)) group.addButton(button) self._mode_buttons[mode] = button tools.addWidget(button) self._xd_button = QPushButton("xD", self) self._xd_button.setCheckable(True) self._xd_button.setToolTip( "Project the chosen measurements onto components and gate on " "those. Independent of 2D/3D \u2014 pick how many axes there " "are separately.\n\nWhich measurements are reduced is the xD " "tab of the settings.") fit_to_text(self._xd_button, padding=18) self._xd_button.toggled.connect(self.projection_requested.emit) tools.addWidget(self._xd_button) volume_tools = QHBoxLayout() volume_tools.setContentsMargins(0, 0, 0, 0) volume_tools.setSpacing(SPACING["xs"]) self._plane_label = QLabel("plane", self) volume_tools.addWidget(self._plane_label) self._plane_buttons: Dict[str, QPushButton] = {} plane_group = QButtonGroup(self) plane_group.setExclusive(True) for axis in ("x", "y", "z"): button = QPushButton(axis.upper(), self) button.setCheckable(True) button.setChecked(axis == "z") button.setToolTip( f"Draw on the plane facing {axis.upper()}, and extend the " f"shape along {axis.upper()}. The chosen plane carries a blue " f"aura, and spinning the view does not change it.") button.clicked.connect( lambda _checked=False, a=axis: self._on_plane_picked(a)) fit_to_text(button, padding=14) plane_group.addButton(button) self._plane_buttons[axis] = button volume_tools.addWidget(button) self._volume_shape = QComboBox(self) self._volume_shape.setObjectName("VolumeShapePicker") for key, label in VOLUME_SHAPES: self._volume_shape.addItem(tr(label), key) self._volume_shape.setToolTip(tr( "What a drag in Draw mode makes. Lasso and Rectangle through view " "work at any angle: turn the volume until the population stands " "apart, draw around it, and the gate keeps every object whose " "position on that view falls inside the outline. Box, oval, " "circle and polygon are drawn on the chosen plane and given a " "depth with a second drag. A right-button drag always turns the " "volume.") + "\n" + tr( "Polygon through view: click the corners, then click the first " "one again. Ellipsoid and Box with handles fit the objects a " "dragged rectangle frames; pull their handles to adjust them.")) self._volume_shape.currentIndexChanged.connect( lambda _i: self._on_volume_shape_picked()) volume_tools.addWidget(self._volume_shape) self._box_gate = QPushButton("From view", self) self._box_gate.setToolTip( "Turn what is currently in view into a gate on all three " "measurements — frame a population by spinning and zooming, then " "keep what you framed. The shape dropdown is the other way to " "make one: draw it on the chosen plane.") self._box_gate.clicked.connect(self.gate_from_view) fit_to_text(self._box_gate) volume_tools.addWidget(self._box_gate) self._drag_label = QLabel("drag", self) volume_tools.addWidget(self._drag_label) self._drag_buttons: Dict[str, QPushButton] = {} drag_group = QButtonGroup(self) drag_group.setExclusive(True) for mode, tip in (("spin", "Drag to turn the volume."), ("draw", "Drag to draw the chosen shape on the " "chosen plane.")): button = QPushButton(mode.capitalize(), self) button.setCheckable(True) button.setChecked(mode == "spin") button.setToolTip(tip) button.clicked.connect( lambda _checked=False, m=mode: self._on_drag_mode(m)) fit_to_text(button, padding=14) drag_group.addButton(button) self._drag_buttons[mode] = button volume_tools.addWidget(button) self._spin_label = QLabel("spin", self) volume_tools.addWidget(self._spin_label) self._spin_buttons: Dict[str, QPushButton] = {} spin_group = QButtonGroup(self) spin_group.setExclusive(True) for axis in ("", "x", "y", "z"): button = QPushButton(axis.upper() or tr("Free"), self) button.setCheckable(True) button.setChecked(axis == "") if axis: button.setToolTip(tr( "Spin about {axis} only: a drag turns the volume about " "that measurement's axis and leaves it where it is on " "screen.", axis=axis.upper())) else: button.setToolTip(tr( "Turn the volume freely: drag sideways and up and down " "at once, and it stays wherever you let go.")) button.clicked.connect( lambda _checked=False, a=axis: self.spin_axis_changed.emit(a)) fit_to_text(button, padding=14) spin_group.addButton(button) self._spin_buttons[axis] = button volume_tools.addWidget(button) volume_tools.addStretch(1) self.set_spin_controls_visible(False) self._status = QLabel("no gates", self) self._status.setObjectName("GateStatus") self._status.setWordWrap(True) tools.addWidget(self._status, 1) self.tool_row = tools outer.addLayout(tools) outer.addLayout(volume_tools) from .collapsible_splitter import CollapsibleSplitter self.body = CollapsibleSplitter(Qt.Horizontal, self, persist_key=GRAPH_SPLIT_KEY) self.canvas = GateCanvas(self, link=link, source=source) self.canvas.set_volume_shape(self.volume_shape()) self.canvas.gate_drawn.connect(self._on_gate_drawn) self.canvas.wand_failed.connect(self._status.setText) self.canvas.depth_requested.connect(self._status.setText) self.canvas.gate_edited.connect(self._on_gate_edited) self.canvas.polygon_changed.connect(self._on_polygon_changed) self.tree = GateTree(self) self.tree.setMinimumWidth(220) self.tree.active_changed.connect(self._on_active_changed) self.tree.gates_changed.connect(self._on_tree_changed) self.tree.enabled_changed.connect(self.canvas.set_gate_enabled) self.tree.set_colour_source(self.canvas.gate_colour) self.canvas_section = self.body.add_section( self.canvas, "Graph", persist_key=f"{FOLD_KEY}/Graph", stretch=1) self.tree_section = self.body.add_section( self.tree, "Gate table", persist_key=f"{FOLD_KEY}/Gate table", stretch=0) outer.addWidget(self.body, 1) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self) self._install_gate_undo() def _install_gate_undo(self) -> None: """Make every committed gate edit undoable with Ctrl+Z and Ctrl+Shift+Z. The whole gate set is recorded after each :attr:`gates_changed`, so a drawn, edited, renamed, removed or loaded gate is one step, and a step put back is replayed through :meth:`set_gates`. """ from PySide6.QtGui import QUndoStack from ..shortcuts import _bind_undo_keys self.undo_stack = QUndoStack(self) self._gate_undo_state = self._gates_snapshot() self._gate_undo_replaying = False self.gates_changed.connect(self._record_gate_edit) _bind_undo_keys(self, self.undo_stack) def _gates_snapshot(self) -> Dict[str, Any]: """The current gate set as plain data, for the undo stack.""" try: return self._gates.to_dict() except Exception: # noqa: BLE001 return {} def _record_gate_edit(self) -> None: """Push the edit that just changed the gate set onto the undo stack.""" from ..shortcuts import _record_edit state = self._gates_snapshot() if self._gate_undo_replaying: self._gate_undo_state = state return before, self._gate_undo_state = self._gate_undo_state, state _record_edit(self.undo_stack, tr("Edit gates"), self._restore_gates, before, state) def _restore_gates(self, state: Dict[str, Any]) -> None: """Put the gate set recorded as ``state`` back on the panel.""" self._gate_undo_replaying = True try: self.set_gates(GateSet.from_dict(state)) finally: self._gate_undo_replaying = False
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Point the panel at a new table. :param frame: the rows to gate, or None to clear. """ self._frame = frame self.canvas.set_frame(frame) self.tree.set_gates(self._gates, frame) self._refresh_status()
[docs] def set_spec(self, spec: GraphSpec) -> None: """Draw a different chart under the gates. :param spec: the graph spec for the canvas. """ self.canvas.set_spec(spec)
@property
[docs] def gates(self) -> GateSet: """The gates currently drawn. :returns: the gate set. """ return self._gates
[docs] def set_gates(self, gates: GateSet) -> None: """Replace the whole set — loading a saved gating strategy. :param gates: the gate set, handed to both the canvas and the tree. """ self._gates = gates self.canvas.set_gates(gates, active=self.tree.active_gate() or None) self.tree.set_gates(gates, self._frame) self._refresh_status() self.gates_changed.emit()
[docs] def set_namer(self, namer) -> None: """Install ``namer() -> str`` to name a freshly drawn gate. Injectable so a test can name gates without a modal dialog standing in a headless run's way — the same reason :class:`~spacr.qt.widgets.data_filter_panel.DataFilterPanel` takes an injectable link. :param namer: zero-argument callable returning the new gate's name. """ self._namer = namer
def _on_tool_changed(self, *_args) -> None: """Arm a different drawing tool. :param _args: the signal's payload; the tool is re-read from the buttons. """ tool = self._tool.currentData() or "" self.canvas.set_tool(tool) self._refresh_status() def _on_polygon_changed(self, count: int) -> None: """Enable Finish only once the outline has enough vertices. :param count: how many vertices are placed. """ if count: self._status.setText( f"{count} vertex(es) — three or more make a region") def _on_cluster(self) -> None: """The Cluster… button: ask, then run.""" self.run_cluster(ask=True)
[docs] def run_cluster(self, *, ask: bool = True) -> None: """Find dense populations and add one gate per cluster. Clusters become REAL gates rather than a separate kind of selection, so each is editable, nestable, serialisable and usable as a filter the moment it appears -- everything a hand-drawn gate can do, because it is one. :param ask: open the parameter dialog first. The Cluster… button does; the Search TAB does not, because the tab IS the parameter editor -- asking again there would be asking twice for the same numbers. Both read the same settings object, which is what stops the two from disagreeing. """ from PySide6.QtWidgets import QMessageBox frame = self.canvas.population() if frame is None or frame.empty: QMessageBox.information( self, "Nothing to cluster", "Load a table before clustering.") return spec = self.canvas.spec x_column = getattr(spec, "x", None) or "" y_column = getattr(spec, "y", None) or "" if not x_column or not y_column: QMessageBox.information( self, "Pick two measurements", "Clustering needs an X and a Y measurement.") return if ask: dialog = _ClusterSettingsDialog(self, settings=self._settings) if dialog.exec() != QDialog.Accepted: return params = _ClusterRun(dialog.eps(), dialog.min_samples(), dialog.scale(), dialog.walk(), dialog.walk_steps(), dialog.method()) else: settings = self._settings params = _ClusterRun( float(getattr(settings, "cluster_eps", 0.5)), int(getattr(settings, "cluster_min_samples", 20)), bool(getattr(settings, "cluster_scale", True)), bool(getattr(settings, "cluster_walk", False)), int(getattr(settings, "cluster_walk_steps", 12)), str(getattr(settings, "cluster_method", "dbscan"))) from .gate_spec import (ClusterError, best_cluster_candidate, cluster_gates, cluster_walk_candidates) eps = params.eps chosen = None try: if params.walk: candidates = cluster_walk_candidates( frame, x_column, y_column, eps=eps, min_samples=params.min_samples, scale=params.scale, steps=params.walk_steps, method=params.method) chosen = best_cluster_candidate(candidates) if chosen is None: tried = ", ".join(f"{c.eps:.3g}" for c in candidates) QMessageBox.information( self, "The walk found nothing to recommend", "No radius produced two or more populations while " "keeping most of the objects.\n\nTried: " f"{tried}\n\nLower min samples, pick measurements " "that separate the populations, or turn the walk " "off and set eps yourself.") return eps = chosen.eps found = cluster_gates( frame, x_column, y_column, eps=eps, min_samples=params.min_samples, scale=params.scale, method=params.method, parent=self.canvas.active_gate) except ClusterError as exc: QMessageBox.warning(self, "Could not cluster", str(exc)) return if not found: QMessageBox.information( self, "No clusters", "DBSCAN found only sparse points at these settings. Raise " "eps to group them more loosely, or lower min_samples.") return gates = self._gates for gate in found: gates.add(gate) self.canvas.set_gates(gates, active=found[0].name) self.tree.set_gates(gates, self._frame) self.tree.select(found[0].name) self._refresh_status() self.gates_changed.emit() if chosen is not None: QMessageBox.information( self, "Walk finished", f"Clustered at eps {chosen.eps:.3g}, which gave " f"{chosen.clusters} populations and left " f"{chosen.noise_fraction:.0%} of objects outside them.")
def _on_gate_edited(self, gate: Gate) -> None: """Replace a gate that was dragged on the canvas. By NAME, so the hierarchy is untouched: a moved child stays a child. `GateSet.add` replaces an existing name rather than appending, which is what makes this a one-liner instead of a remove-then-add that could lose the gate if the add failed. """ gates = self.gates gates.add(gate) self.canvas.set_gates(gates, active=gate.name) self._refresh_status() def _on_gate_drawn(self, gate: Gate) -> None: """Name a newly drawn gate and add it to the set. :param gate: the gate the canvas produced. """ name = self._ask_name() if not name: self.canvas.render_now() return try: self._gates.add(gate.rename(name)) except GateError as exc: self._status.setText(str(exc)) return self.canvas.set_gates(self._gates, active=self.tree.active_gate() or None) self.tree.set_gates(self._gates, self._frame) self.tree.select(name) self._refresh_status() self.gates_changed.emit() def _ask_name(self) -> str: """Ask what to call a gate, defaulting to a free name. :returns: the chosen name, or ``""`` if cancelled. """ if self._namer is not None: return str(self._namer() or "") name, ok = QInputDialog.getText( self, "Name this gate", "A gate is not a gate until it is named — the name is what makes " "it re-appliable and what the hierarchy is read by:") return name.strip() if ok else "" def _on_active_changed(self, name: str) -> None: """Redraw for a different active gate. :param name: the gate now active. """ self.canvas.set_gates(self._gates, active=name or None) self._refresh_status() if not name: return try: columns = self._gates.get(name).columns except Exception: return if columns: self.axes_requested.emit(columns[0], columns[1] if len(columns) > 1 else "") def _on_tree_changed(self) -> None: """Re-apply the gates after the hierarchy was edited.""" self.canvas.set_gates(self._gates, active=self.tree.active_gate() or None) self._refresh_status() self.gates_changed.emit()
[docs] def publish(self) -> Optional[DataFilter]: """Publish objects inside the selected gate as a shared selection. Objects outside the gate remain visible. The status label reports missing input, evaluation errors, and tables that lack shareable object identifiers. """ name = self.tree.active_gate() if not name: self._status.setText("Select a gate in the hierarchy first.") return None frame = self._frame if frame is None: self._status.setText("Load a table first.") return None try: inside = self._gates.mask(frame, name) except GateError as exc: self._status.setText(str(exc)) return None try: self.canvas.publish_selection(frame.loc[inside]) except Exception as exc: self._status.setText( f"{int(inside.sum()):,} object(s) in {name}, but they cannot " f"be shared with other views: {exc}") return None self._status.setText( f"{int(inside.sum()):,} object(s) highlighted by {name}") return None
[docs] def status(self) -> str: """Whatever the status line is telling the user. :returns: the status text. """ return self._status.text()
[docs] def volume_shape(self) -> str: """The shape a drag on the anchor plane would draw.""" picker = getattr(self, "_volume_shape", None) return str(picker.currentData()) if picker is not None else "box"
[docs] def anchor_axis(self) -> str: """Which axis the chosen plane faces, and the shape extends along.""" for axis, button in getattr(self, "_plane_buttons", {}).items(): if button.isChecked(): return axis return "z"
[docs] def drag_mode(self) -> str: """``'spin'`` or ``'draw'`` -- what a drag on the volume does.""" for mode, button in getattr(self, "_drag_buttons", {}).items(): if button.isChecked(): return mode return "spin"
def _on_plane_picked(self, axis: str) -> None: """Rotate a 3-D view to look down one axis. :param axis: the axis to look along. """ button = getattr(self, "_plane_buttons", {}).get(axis) if button is not None and not button.isChecked(): button.setChecked(True) self.canvas.set_anchor_axis(axis) button = getattr(self, "_drag_buttons", {}).get("draw") if button is not None and not button.isChecked(): button.setChecked(True) self._on_drag_mode("draw") def _on_volume_shape_picked(self) -> None: """Arm the picked 3-D shape, and make the next drag draw it. Picking a shape is asking to draw one; leaving the drag on Spin made the first attempt turn the volume instead. """ self.canvas.set_volume_shape(self.volume_shape()) button = getattr(self, "_drag_buttons", {}).get("draw") if button is not None and not button.isChecked(): button.setChecked(True) self._on_drag_mode("draw") self.canvas.render_now() def _on_drag_mode(self, mode: str) -> None: """Switch dragging between spinning the view and drawing. :param mode: the mode's name. """ self.canvas.set_drag_mode(mode)
[docs] def set_projection_active(self, on: bool) -> None: """Show the xD button as on or off without re-emitting. Used when a projection was asked for and could not be made: the button must not keep claiming something that did not happen. :param on: the checked state to show. """ button = getattr(self, "_xd_button", None) if button is None: return blocked = button.blockSignals(True) button.setChecked(bool(on)) button.blockSignals(blocked)
[docs] def set_spin_controls_visible(self, visible: bool) -> None: """Show or hide the 3-D plane controls. Hidden for a 2-D chart, where an axis picker and a spin toggle are controls for something the view cannot do. :param visible: True to show them. """ self._plane_label.setVisible(visible) for button in self._plane_buttons.values(): button.setVisible(visible) self._volume_shape.setVisible(visible) self._box_gate.setVisible(visible) self._drag_label.setVisible(visible) for button in self._drag_buttons.values(): button.setVisible(visible) self._spin_label.setVisible(visible) for button in self._spin_buttons.values(): button.setVisible(visible)
[docs] def gate_from_view(self) -> None: """Make a box gate out of what the volume currently shows.""" gate = self.canvas.box_from_view() if gate is None: self._status.setText( "a box gate needs three measurements on screen; choose a Z") return self._on_gate_drawn(gate)
[docs] def reset_view(self) -> None: """Undo a zoom and a spin in one place. One button for both because from the user's side there is one problem -- "the graph is not where it was" -- and having to know whether they zoomed or rotated to get out of it is the kind of distinction only the implementation cares about. """ self.canvas.reset_view()
[docs] def apply_settings(self, settings) -> None: """Take the settings that change how the gates surface draws. The canvas takes the drawing ones. Sampling is the screen's job -- it owns the table and the read -- and the 3D ones belong to a workspace that does not exist yet. A setting silently read in two places is how the two get to disagree. The CLUSTERING ones are kept here rather than passed on, because the Cluster button is on this panel and used to ignore them entirely. :param settings: a :class:`~spacr.qt.widgets.gate_settings.GateEditorSettings` (or a compatible object); kept by the panel and passed to :meth:`GateCanvas.apply_settings`. """ self._settings = settings self.canvas.apply_settings(settings) self._refresh_status()
def _refresh_status(self) -> None: """Say how many gates there are and what the active one selects.""" if self._frame is None: self._status.setText("no table loaded") return active = self.tree.active_gate() population = self.canvas.population() n = 0 if population is None else len(population) parts = [f"{n:,} objects"] if len(self._gates): showing = len(self.canvas.enabled_gates) parts.append(f"{showing} of {len(self._gates)} gate(s) shown") if active: parts.append(f"next gate inside {active}") self._status.setText(" · ".join(parts))
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Close the canvas first, so it can unlink from the shared selection. :param event: the Qt close event. """ self.canvas.close() super().closeEvent(event)