Source code for spacr.qt.widgets.graph_builder

"""Graph Builder — drag a column onto a channel and the chart appears.

The direct-manipulation surface that replaces "which of the forty ``plot_*``
functions do I want, and what does it expect?". Six drop zones — x, y, colour,
size, facet-row, facet-column — a well of the columns worth plotting, and a
canvas that re-renders the moment a zone changes.

What is in here and what is not
-------------------------------

Everything about *what to draw* lives in :mod:`spacr.qt.widgets.graph_spec`:
the spec object, the plot-type inference, the facet grid, the shared scales
and the large-data policy. This module is the chrome and the matplotlib calls.
The split is not tidiness — small multiples, the gate editor, the feature
explorer and the campaign control charts are all "the graph builder with one
more rule", and they need the engine without inheriting a drag-and-drop panel.

Linked, and asymmetric on purpose
---------------------------------

:class:`GraphCanvas` mixes in
:class:`spacr.qt.linked_selection.LinkedView`, so it is one of the views that
talk to each other:

* a **brush** (drag a rectangle across a panel) publishes the rows it swept as
  the shared selection;
* an incoming **selection** rings those rows and dims the rest. It never
  removes a point — a selection highlights, it does not hide;
* an incoming **filter** does remove rows, and the axes re-scale to what is
  left, because a filter genuinely narrows the population.

The brush is evaluated as a *predicate over the frame*, not as a hit test
against drawn marks, which is what keeps it exact when a panel was drawn as a
density raster or from a sample.

Colour
------

Categorical series take a fixed eight-hue order — never cycled, never
re-assigned when a filter changes the series count, so a gene keeps its colour
between two charts. The order is the validated reference palette (light and
dark steps kept separately rather than flipped), and a continuous colour
column gets a single-hue light-to-dark ramp. Beyond eight levels the extras
fold into one "other" grey rather than inventing hues nobody can tell apart.
"""
from __future__ import annotations

import logging
from typing import Callable, Dict, List, Optional, Tuple

import numpy as np
import pandas as pd
from PySide6.QtCore import QMimeData, Qt, QTimer, Signal
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import (
    QAbstractItemView, QComboBox, QFrame, QGridLayout, QHBoxLayout,
    QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
    QSpinBox, QVBoxLayout, QWidget,
)

from ...selection import Selection, object_keys
from ..linked_selection import LinkedView
from ..theme import (RADIUS, SPACING, active_palette, apply_close_mark,
                     font_px, make_transparent, paint_panel,
                     register_widget_qss)
from .graph_spec import (
    BAR, BAR_JITTER, BINNED, BOX, CHANNELS, COLOUR, EMPTY, FACET_COL,
    FACET_ROW, HEATMAP, HISTOGRAM, JITTER, LINE, MISSING_LEVEL, PLOT_KINDS,
    SCATTER, SIZE, VIOLIN, X, Y,
    GraphSpec, RenderData, brush_mask, facet_grid, plottable_columns,
    prepare_data, scales_for, value_axes,
)
from .toggle import Toggle

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

__all__ = [
    "COLUMN_MIME", "CHANNEL_LABELS", "ColumnWell", "DropZone", "GraphCanvas",
    "GraphBuilderPanel", "categorical_colours", "sequential_colours",
]

#: The drag payload. Its own type rather than ``text/plain`` so a column
#: dragged out of the well cannot be dropped into an unrelated text field, and
#: a path dragged in from the file manager cannot be read as a column name.
COLUMN_MIME = "application/x-spacr-graph-column"

#: Drop-zone captions, and the order they are laid out in.
CHANNEL_LABELS = {
    X: "X",
    Y: "Y",
    COLOUR: "Colour",
    SIZE: "Size",
    FACET_ROW: "Facet ↓",
    FACET_COL: "Facet →",
}

#: One-line "what does this zone do" for the tooltips.
CHANNEL_HINTS = {
    X: "Horizontal axis. One continuous column alone draws a histogram.",
    Y: "Vertical axis. A categorical column here and a continuous one on X "
       "draws boxes.",
    COLOUR: "Hue. A categorical column takes the fixed series order; a "
            "continuous one takes a light-to-dark ramp.",
    SIZE: "Mark area. Point plots only — an aggregate ignores it.",
    FACET_ROW: "One row of panels per level, with shared axes.",
    FACET_COL: "One column of panels per level, with shared axes.",
}

#: The categorical series order, light-surface steps then dark-surface steps.
#: Assigned by position and never cycled: the ninth level is folded into
#: :data:`OTHER_COLOUR`, because a generated ninth hue is one nobody can
#: separate from the eight already there.
_SERIES_LIGHT = ("#2a78d6", "#eb6834", "#1baf7a", "#eda100",
                 "#e87ba4", "#008300", "#4a3aa7", "#e34948")
_SERIES_DARK = ("#3987e5", "#d95926", "#199e70", "#c98500",
                "#d55181", "#008300", "#9085e9", "#e66767")

#: Where levels past the eighth go.
OTHER_COLOUR = "#898781"
OTHER_LABEL = "other"

#: Single-hue magnitude ramp, light → dark, for a continuous colour column and
#: for the density raster. One hue, never a rainbow.
_RAMP_LIGHT = ("#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#104281")
_RAMP_DARK = ("#0d366b", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4")

#: How much opacity an unselected mark keeps while a selection is live. Dim,
#: never hidden — the shape of what was *not* selected is half the answer.
DIMMED_ALPHA = 0.16

#: Redraws are coalesced this long, so dragging a spinbox costs one render.
DEBOUNCE_MS = 120


def _is_light_surface() -> bool:
    """Whether the active theme's chart surface is a light one.

    Derived from the surface colour rather than a theme name so a theme added
    later gets the right series steps without touching this file.
    """
    try:
        surface = active_palette()["surface"]
        text = str(surface).lstrip("#")[:6]
        r, g, b = (int(text[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
    except Exception:
        return False
    return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 0.5


[docs] def categorical_colours() -> Tuple[str, ...]: """The fixed eight-hue series order for the active theme.""" return _SERIES_LIGHT if _is_light_surface() else _SERIES_DARK
[docs] def sequential_colours() -> Tuple[str, ...]: """The single-hue magnitude ramp for the active theme, light → dark.""" return _RAMP_LIGHT if _is_light_surface() else _RAMP_DARK
def _colormap(): """Build the sequential colour map graphs are drawn with. :returns: the map, from spaCR's own sequential colours rather than a matplotlib default, so a figure matches the application around it. """ from matplotlib.colors import LinearSegmentedColormap return LinearSegmentedColormap.from_list( "spacr_graph_seq", list(sequential_colours())) def _orientation(vertical: bool) -> dict: """``boxplot``/``violinplot`` orientation, spelled the way this matplotlib wants it. 3.10 replaced ``vert=True`` with ``orientation="vertical"`` and warns on the old spelling. spaCR is installed against both, and a ``PendingDeprecationWarning`` per panel per render is noise that hides the warnings worth reading. """ import matplotlib parts = matplotlib.__version__.split(".") try: modern = (int(parts[0]), int(parts[1])) >= (3, 10) except (IndexError, ValueError): modern = True if modern: return {"orientation": "vertical" if vertical else "horizontal"} return {"vert": bool(vertical)}
[docs] class ColumnWell(QWidget): """The list of plottable columns, filtered by a search box, draggable out. Only the columns :func:`spacr.qt.widgets.graph_spec.plottable_columns` offers — the same rule the Local Data Filter uses to decide what is worth a control. A measurement table has hundreds of columns and listing all of them is the same as listing none. :param parent: parent widget. """ def __init__(self, parent=None): """Build the well of draggable columns. :param parent: parent widget. """ super().__init__(parent) self.setObjectName("GraphColumnWell") self._columns: Tuple[str, ...] = () self._kinds: Dict[str, str] = {} outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) self._search = QLineEdit(self) self._search.setObjectName("GraphColumnSearch") self._search.setPlaceholderText("Find a column…") self._search.setClearButtonEnabled(True) self._search.textChanged.connect(self._refilter) outer.addWidget(self._search) self._list = _DraggableList(self) self._list.setObjectName("GraphColumnList") outer.addWidget(self._list, 1) self._count = QLabel("no table loaded", self) self._count.setObjectName("GraphColumnCount") outer.addWidget(self._count)
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Re-list the plottable columns for a new table. ``None`` empties the well rather than leaving the previous table's columns on screen, which would offer drags that cannot land. :param frame: the table to read columns from, or None. """ if frame is None: self._columns = () self._kinds = {} else: self._columns = plottable_columns(frame) from .graph_spec import column_kinds self._kinds = column_kinds(frame) self._refilter()
[docs] def columns(self) -> Tuple[str, ...]: """Every offered column, whatever the search box currently shows.""" return self._columns
[docs] def visible_columns(self) -> List[str]: """The columns the search box is currently letting through. Read off the LIST rather than refiltered, so it is what the user can actually see and drag. :returns: the visible column names, in list order. """ return [self._list.item(i).data(Qt.UserRole) for i in range(self._list.count())]
def _refilter(self) -> None: """Re-list the columns matching the search box.""" needle = self._search.text().strip().lower() self._list.clear() for name in self._columns: if needle and needle not in name.lower(): continue kind = self._kinds.get(name, "") item = QListWidgetItem(f"{name} · {kind[:4]}") item.setData(Qt.UserRole, name) item.setToolTip(f"{name} — {kind}\nDrag onto a channel.") self._list.addItem(item) shown = self._list.count() total = len(self._columns) self._count.setText( "no table loaded" if not total else f"{shown} of {total} columns" if shown != total else f"{total} columns")
class _DraggableList(QListWidget): """A list whose items leave as :data:`COLUMN_MIME` payloads. :param parent: parent widget; ownership only. """ def __init__(self, parent=None): """Build the list as a drag SOURCE that takes no drops.""" super().__init__(parent) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragOnly) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setAlternatingRowColors(False) def mimeData(self, items) -> QMimeData: # noqa: N802 - Qt name """Build the drag payload for a dragged column. A plain-text copy rides alongside the typed payload, so dropping a column into a text field elsewhere pastes its NAME rather than nothing. :param items: the dragged items. :returns: the payload; empty when the items carry no column name. """ payload = QMimeData() names = [i.data(Qt.UserRole) for i in items if i.data(Qt.UserRole)] if names: payload.setData(COLUMN_MIME, names[0].encode("utf-8")) payload.setText(names[0]) return payload
[docs] class DropZone(QFrame): """One channel's drop target. Emits :attr:`column_changed` with ``(channel, column_or_empty)``. The empty string rather than ``None`` so the signal can be typed ``str, str`` and connected across a queued connection without a custom metatype. :param channel: which channel this zone accepts. It is carried in every :attr:`column_changed`, so the host does not have to remember which zone it connected. :param parent: parent widget. """ column_changed = Signal(str, str) def __init__(self, channel: str, parent=None): """Build one channel's drop target. :param channel: the channel this zone binds. :param parent: parent widget. """ super().__init__(parent) if channel not in CHANNELS: raise ValueError(f"unknown channel {channel!r}") self.channel = channel self._column: Optional[str] = None self.setObjectName("GraphDropZone") self.setAcceptDrops(True) self.setProperty("filled", False) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setToolTip(CHANNEL_HINTS.get(channel, "")) row = QHBoxLayout(self) row.setContentsMargins(SPACING["sm"], SPACING["xs"], SPACING["xs"], SPACING["xs"]) row.setSpacing(SPACING["xs"]) self._name = QLabel(CHANNEL_LABELS[channel], self) self._name.setObjectName("GraphDropZoneName") row.addWidget(self._name) self._value = QLabel("drop a column", self) self._value.setObjectName("GraphDropZoneValue") self._value.setWordWrap(False) row.addWidget(self._value, 1) self._clear = QPushButton(self) self._clear.setObjectName("GraphDropZoneClear") apply_close_mark( self._clear, tooltip=f"Take the column off {CHANNEL_LABELS[channel]}") self._clear.setVisible(False) self._clear.clicked.connect(lambda: self.set_column(None)) row.addWidget(self._clear) @property
[docs] def column(self) -> Optional[str]: """The column bound to this channel, if any. :returns: the column name, or None when the zone is empty. """ return self._column
[docs] def set_column(self, column: Optional[str]) -> None: """Put ``column`` on this channel (``None`` empties it) and announce it. Silent when nothing changes: the panel rebuilds the chart on every emission, and a re-drop of the same column would otherwise cost a full re-render for no visible difference. :param column: column name for this channel, converted with ``str()``; None or an empty string empties the zone. """ column = str(column) if column else None if column == self._column: return self._column = column self._value.setText(column or "drop a column") self._value.setToolTip(column or "") self._clear.setVisible(bool(column)) self.setProperty("filled", bool(column)) self.style().unpolish(self) self.style().polish(self) self.column_changed.emit(self.channel, column or "")
def _accepts(self, event) -> bool: """Whether this drag carries a column this zone can take. :param event: the Qt drag event. :returns: True when droppable. """ return event.mimeData() is not None and \ event.mimeData().hasFormat(COLUMN_MIME)
[docs] def dragEnterEvent(self, event): # noqa: N802 - Qt name """Light up when a droppable column arrives over the zone. :param event: the Qt drag event. """ if self._accepts(event): self.setProperty("hovered", True) self.style().unpolish(self) self.style().polish(self) event.acceptProposedAction() else: event.ignore()
[docs] def dragMoveEvent(self, event): # noqa: N802 - Qt name """Keep accepting while a droppable column stays over the zone. :param event: the Qt drag event. """ if self._accepts(event): event.acceptProposedAction() else: event.ignore()
[docs] def dragLeaveEvent(self, event): # noqa: N802 - Qt name """Drop the highlight when the pointer leaves. :param event: the Qt drag event. """ self.setProperty("hovered", False) self.style().unpolish(self) self.style().polish(self) super().dragLeaveEvent(event)
[docs] def dropEvent(self, event): # noqa: N802 - Qt name """Bind the dropped column to this channel. :param event: the Qt drop event. """ if not self._accepts(event): event.ignore() return raw = bytes(event.mimeData().data(COLUMN_MIME)).decode("utf-8") self.setProperty("hovered", False) self.style().unpolish(self) self.style().polish(self) self.set_column(raw or None) event.acceptProposedAction()
[docs] def page_alpha() -> float: """The page-opacity preference as a plain float, for matplotlib. Matplotlib takes alpha as a number, not as a QSS colour, so :func:`~spacr.qt.theme.pane_surface` is no help to an axes patch. Degrades to the theme's designed scrim when preferences cannot be read, which is what a first run mid-generation gets. """ from ..theme import panel_alpha theme = "dark" opacity = None try: from ..preferences import get_pane_opacity, resolve_effective_theme theme = resolve_effective_theme() opacity = get_pane_opacity() except Exception: pass return float(panel_alpha(theme, "surface_alt", opacity))
def _page_surface_axes(ax, palette) -> None: """Give ``ax`` a plotting area that follows the page-opacity slider. The axes keep a fill — the plotting area is meant to read as a panel within the panel — but at the page alpha, so the preference reaches the plot rather than stopping at its frame. ``set_facecolor`` with a raw hex would be opaque by construction and would hide the panel the canvas painted underneath. """ ax.patch.set_facecolor(palette["surface_alt"]) ax.patch.set_alpha(page_alpha()) #: Built once, on first use — see :func:`_canvas_class`. _CANVAS_CLASS = None def _canvas_class(): """The figure canvas: a deferred draw it owns, on a page panel. Two problems, one class, because both need the same subclass and no module in this package may import matplotlib's Qt backend at import time. *The timer.* Matplotlib's Qt canvas schedules its idle draw with a static ``QTimer.singleShot``, which is not owned by the canvas and can therefore fire after Qt has deleted it — a segfault on close. An owned timer dies with the widget. The same fix :class:`spacr.qt.widgets.umap_explorer.ImageUmapExplorer` carries. *The slab.* ``FigureCanvasQT.__init__`` sets ``WA_OpaquePaintEvent`` and the figure carries a solid ``facecolor``: two opaque things stacked, with square corners where every other container on the page is rounded. QSS reaches neither — a ``WA_OpaquePaintEvent`` widget never lets the sheet's background through — so the Graph Builder, the Trellis, the Gate Editor, Tabulate, PCA and the Feature Explorer all showed one flat rectangle whatever the page-opacity slider said. The panel is therefore drawn in ``paintEvent``, under a figure whose own patch is fully transparent, exactly as Training Runs does it. Cached, so the six screens share one class rather than one per canvas. """ global _CANVAS_CLASS if _CANVAS_CLASS is not None: return _CANVAS_CLASS from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg class OwnedTimerFigureCanvas(FigureCanvasQTAgg): """A Matplotlib canvas whose redraw timer dies with the widget. Matplotlib's own canvas schedules redraws on a timer it does not parent to the widget, so a queued redraw can fire after the C++ object behind the Python wrapper is gone -- which is a hard crash rather than an exception. Parenting the timer to the canvas makes Qt destroy them together. Defined inside the guard that imported Matplotlib's Qt backend, so the name does not exist when that backend is unavailable rather than raising at import for the whole module. """ def __init__(self, figure, *, panel: bool = True): """Wrap a figure in a canvas that owns its own redraw timer. :param figure: the Matplotlib ``Figure`` to draw. Held by the canvas, which is what "owned" means here -- the timer is a child of the canvas, so the figure and the redraw it schedules are destroyed together and a queued redraw cannot outlive the widget it would paint. :param panel: draw the page surface under the figure. ``False`` for a canvas that is already sitting ON a panel — the scree plot inside the PCA shelf, say. Two surfaces stacked read 0.49 at a requested 30 %, a shade no position of the slider can reach, so the inner one shows the outer panel through instead. """ super().__init__(figure) self._spacr_draw_timer = QTimer(self) self._spacr_draw_timer.setSingleShot(True) self._spacr_draw_timer.timeout.connect(self._spacr_draw) self._spacr_panel = bool(panel) self.setAttribute(Qt.WA_OpaquePaintEvent, False) self.setAttribute(Qt.WA_TranslucentBackground, True) make_transparent(self) figure.patch.set_alpha(0.0) from ..gui_scale import follow_canvas follow_canvas(self) from .figure_settings import _attach_figure_menu _attach_figure_menu(self) def paintEvent(self, event): # noqa: N802 - Qt name """Draw the page panel, then let matplotlib draw over it.""" if self._spacr_panel: painter = QPainter(self) paint_panel(painter, self, role="surface", inset=0.5) painter.end() super().paintEvent(event) def draw_idle(self): """Ask for a redraw on the OWNED timer rather than a static one. Matplotlib's Qt canvas uses static ``QTimer.singleShot``, whose callback is not owned by the canvas and can run after Qt has deleted it. The timer here is a child of the canvas, so it dies with what it would draw. """ self._draw_pending = True try: if not self._spacr_draw_timer.isActive(): self._spacr_draw_timer.start(0) except RuntimeError: self._draw_pending = False def _spacr_draw(self): """Draw once, if a draw is still pending. The flag is cleared FIRST so a draw that schedules another does not lose it. """ if not self._draw_pending: return self._draw_pending = False try: self.draw() except RuntimeError: return def cancel_pending_draw(self): """Drop any queued redraw. Safe on a canvas Qt has already deleted.""" try: self._spacr_draw_timer.stop() except RuntimeError: pass self._draw_pending = False _CANVAS_CLASS = OwnedTimerFigureCanvas return OwnedTimerFigureCanvas
[docs] class GraphCanvas(LinkedView, QWidget): """The matplotlib canvas every built graph is drawn on. A :class:`LinkedView`, so a selection made here propagates to the other views sharing its model, and the base class for :class:`GateCanvas`. :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. """ #: Whether the axes follow a filter. #: #: True here, and tested: for an ordinary chart, filtering down to one #: plate SHOULD rescale to that plate, because the point of the filter #: was to look at it. #: #: The Gate Editor sets it False, and the reason is specific to gating: a #: gate applied as a filter would otherwise rescale the axes to the rows #: it kept, which reads as the plot zooming into the gate. Worse, it #: moves the axes out from under the gate outline still drawn on them, so #: the gate appears to jump or to fill the plot -- and dragging it #: becomes impossible because the ground moves with every apply. RESCALE_ON_FILTER = True #: Emitted after every render with the :class:`RenderData` that was drawn, #: so a host can put the large-data notice in its own status bar. rendered = Signal(object) def __init__(self, parent=None, *, link=None, source: str = "graph_builder"): """Build the canvas and link it to the shared selection. :param parent: parent widget. """ super().__init__(parent) self.setObjectName("GraphCanvas") self._frame: Optional[pd.DataFrame] = None self._spec = GraphSpec() self._kinds: Dict[str, str] = {} self._keyed = False self._filter_note = "" self._visible: Optional[pd.DataFrame] = None self._render_data: Optional[RenderData] = None self._grid = None self._brush_grid = None self._scales = None self._axes: Dict[Tuple[int, int], object] = {} self._axes_at: Dict[int, Tuple[int, int]] = {} self._overlays: Dict[Tuple[int, int], Optional[Callable]] = {} #: Whether the drawn kind can move its highlight without a redraw. self._live_highlight = False self._selected_mask: Optional[np.ndarray] = None self._drag_origin: Optional[Tuple[object, float, float]] = None self._drag_patch = None self._build_ui() self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(DEBOUNCE_MS) self._debounce.timeout.connect(self.render_now) self.link_selection(source, link=link) def _build_ui(self) -> None: """Lay out the figure and its toolbar.""" from matplotlib.figure import Figure outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) self._figure = Figure(figsize=(7.5, 5.0)) self._canvas = _canvas_class()(self._figure) outer.addWidget(self._canvas, 1) self._notice = QLabel("", self) self._notice.setObjectName("GraphNotice") self._notice.setWordWrap(True) outer.addWidget(self._notice) for event, slot in (("button_press_event", self._on_press), ("motion_notify_event", self._on_motion), ("button_release_event", self._on_release)): self._canvas.mpl_connect(event, slot)
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Point the canvas at a table. Channels naming a column the new table does not have are emptied rather than carried over: a spec that half-resolves would draw a chart of fewer variables than the zones claim. :param frame: the table to plot, or None for no table; channels naming a column it lacks are emptied. """ self._take_frame(frame) self.render_now()
def _take_frame(self, frame: Optional[pd.DataFrame]) -> None: """Everything :meth:`set_frame` does except drawing. For a caller that sets a new spec straight after, so the table is drawn once, under the spec it is meant for, rather than once under the old spec and again under the new one. """ self._frame = frame self._kinds = self._spec.kinds_for(frame) if frame is not None else {} self._keyed = False if frame is not None: try: object_keys(frame) self._keyed = True except Exception: self._keyed = False if frame is not None: spec = self._spec for channel in CHANNELS: column = spec.column_for(channel) if column and column not in frame.columns: spec = spec.with_channel(channel, None) self._spec = spec
[docs] def figure(self): """The matplotlib Figure this canvas draws on. Public so a caller can EXPORT the graph without reaching into a private attribute. Do not draw on it from outside -- `set_spec` and the redraw path own its contents. """ return self._figure
@property
[docs] def spec(self) -> GraphSpec: """The graph this canvas is drawing. :returns: the spec. """ return self._spec
@property
[docs] def kinds(self) -> Dict[str, str]: """The loaded table's column kinds, with the spec's role overrides. Public because "is this column continuous here?" is the question every caller of :meth:`spec` asks next, and re-deriving it would risk two answers. """ return dict(self._kinds)
[docs] def set_spec(self, spec: GraphSpec, *, immediate: bool = True) -> None: """Replace the spec and redraw. :param spec: the chart specification to draw; column kinds are recomputed from it for the current table. """ self._spec = spec if self._frame is not None: self._kinds = spec.kinds_for(self._frame) if immediate: self.render_now() else: self._debounce.start()
[docs] def set_channel(self, channel: str, column: Optional[str]) -> None: """Rebind one channel and redraw. :param channel: the channel's name, such as ``x`` or ``colour``. :param column: the column to bind, or None to clear it. """ self.set_spec(self._spec.with_channel(channel, column))
@property
[docs] def render_data(self) -> Optional[RenderData]: """What the last draw actually plotted, or None before the first. The rendered data rather than the source table: a large frame is sampled or binned before it is drawn, and this is what is on screen. :returns: the render data, or None. """ return self._render_data
@property
[docs] def grid(self): """The facet grid the last draw laid out, or None when unfaceted. :returns: the grid. """ return self._grid
@property
[docs] def scales(self): """The axis limits the last draw used. :returns: the scales. """ return self._scales
[docs] def panel_axes(self) -> Dict[Tuple[int, int], object]: """``{(row, col): Axes}`` for every panel, empty ones included.""" return dict(self._axes)
[docs] def axes_at(self, row: int = 0, col: int = 0): """The matplotlib axes at one facet position. :param row: the grid row, from 0. :param col: the grid column, from 0. :returns: the axes, or None when that position was not drawn. """ return self._axes.get((row, col))
[docs] def notice(self) -> str: """The line under the chart: what was drawn, and out of how much.""" return self._notice.text()
[docs] def selected_count(self) -> int: """Rows of the drawn frame the shared selection names.""" if self._selected_mask is None: return 0 return int(self._selected_mask.sum())
[docs] def render_now(self) -> None: """Rebuild the figure from the current frame, spec, filter and selection.""" self._debounce.stop() self._figure.clear() self._figure.patch.set_alpha(0.0) self._axes = {} self._axes_at = {} self._overlays = {} self._drag_patch = None palette = active_palette() if self._frame is None or self._frame.empty: self._render_message( "Load a table, then drag a column onto X or Y.") return self._visible, self._filter_note = self._apply_filter(self._frame) spec = self._spec kinds = self._kinds kind = spec.resolved_kind(kinds) if kind == EMPTY: self._render_message( "Drag a column onto X or Y.\n" "One continuous column draws a histogram; two draw a scatter; " "one of each draws boxes.") return binding_error = spec.binding_error(kinds) if binding_error: self._render_message(binding_error) return data = prepare_data(self._visible, spec, kinds) grid = facet_grid(data.frame, spec, levels_source=self._visible) self._brush_grid = (grid if data.frame is self._visible else facet_grid(self._visible, spec, levels_source=self._visible)) scale_source = data.frame if not self.RESCALE_ON_FILTER and self._frame is not None: scale_source = self._frame scales = scales_for(scale_source, spec, kinds, grid) self._render_data = data self._grid = grid self._scales = scales self._live_highlight = (kind == SCATTER and data.strategy != BINNED) self._selected_mask = self._selection_mask(data.frame) nrows, ncols = grid.shape axes = self._figure.subplots( nrows, ncols, squeeze=False, sharex=bool(spec.shared_x), sharey=bool(spec.shared_y)) for panel in grid.panels: ax = axes[panel.row][panel.col] self._axes[(panel.row, panel.col)] = ax self._axes_at[id(ax)] = (panel.row, panel.col) self._style_axes(ax, palette) rows = panel.frame(data.frame) mask = (self._selected_mask[panel.index] if self._selected_mask is not None else None) overlay = self._draw_panel(ax, rows, mask, kind, data, palette) self._overlays[(panel.row, panel.col)] = overlay self._apply_scales(ax, kind, scales, panel) self._label_panel(ax, panel, grid, nrows, ncols, palette) self._draw_legend(kind, palette) from ...figures.style import _apply_user_style _apply_user_style(self._figure, force=True) self._figure.tight_layout(pad=0.8) from ...figures.bundle import _register_figure_data _register_figure_data(self._figure, self._visible, x=spec.x or "", y=spec.y or "", hue=spec.colour or "", kind=kind) self._canvas.draw_idle() self._notice.setText(self._notice_text(data, grid)) self.rendered.emit(data)
def _render_message(self, text: str) -> None: """Draw a sentence in place of a chart. FOR THE STATES A CHART CANNOT SHOW: no columns bound yet, a column that is all null, a filter that left nothing. An empty axes would look like a bug rather than an answer. :param text: what to say. """ palette = active_palette() ax = self._figure.add_subplot(111) _page_surface_axes(ax, palette) for spine in ax.spines.values(): spine.set_visible(False) ax.set_xticks([]) ax.set_yticks([]) ax.text(0.5, 0.5, text, ha="center", va="center", wrap=True, color=palette["fg_muted"], fontsize=10, transform=ax.transAxes) self._render_data = None self._grid = None self._brush_grid = None self._scales = None self._selected_mask = None self._live_highlight = False self._canvas.draw_idle() self._notice.setText("") def _apply_filter(self, frame: pd.DataFrame) -> Tuple[pd.DataFrame, str]: """``frame`` narrowed by the shared filter, plus a note if it could not be. A filter naming a column this table does not have is reported rather than swallowed: the alternative is a chart of more rows than the filter panel says are in the population. """ try: return self.linked_visible(frame), "" except Exception as exc: LOG.info("the shared filter does not apply to this table: %s", exc) return frame, f" · the shared filter does not apply here ({exc})" def _selection_mask(self, frame: pd.DataFrame) -> Optional[np.ndarray]: """Which drawn rows the shared selection names, or ``None`` at rest. ``None`` and "an all-False mask" are different: the first is nobody having selected anything, the second is a brush that caught nothing. Only the second dims the rest of the chart. """ selection = self.link.selection if not selection.is_active or not self._keyed or frame.empty: return None try: return selection.mask_for(frame) except Exception: LOG.debug("could not resolve the shared selection here", exc_info=True) return None def _style_axes(self, ax, palette) -> None: """Recessive chrome: hairline grid, two spines, muted ticks.""" _page_surface_axes(ax, palette) ax.grid(True, color=palette["border_soft"], linewidth=0.6, alpha=0.5) ax.set_axisbelow(True) for side in ("top", "right"): ax.spines[side].set_visible(False) for side in ("left", "bottom"): ax.spines[side].set_color(palette["border"]) ax.spines[side].set_linewidth(0.8) ax.tick_params(colors=palette["fg_muted"], labelsize=8, length=3) def _series_colour(self, index: int) -> str: """The colour one series is drawn in. :param index: the series' position. :returns: the colour. """ order = categorical_colours() return order[index] if index < len(order) else OTHER_COLOUR def _level_colours(self, values: pd.Series) -> Tuple[np.ndarray, List[str]]: """Per-row hue for a categorical colour column, by fixed level order. The level's position in :attr:`Scales.colour_levels` picks the hue, not its rank in this panel — so a filter that removes a gene does not repaint the genes that survive. """ levels = list(self._scales.colour_levels or ()) index = {level: i for i, level in enumerate(levels)} text = values.astype(str) colours = np.array( [self._series_colour(index.get(v, len(levels))) for v in text], dtype=object) return colours, levels def _draw_plain_points(self, ax, x, y, rows, palette): """Points with no colour column: one flat colour. Its own method because that is exactly the case the Gate Editor replaces -- a cytometry scatter with no colour axis should still show where the objects are, and a single colour cannot. """ return ax.scatter(x, y, s=self._sizes(rows), color=self._series_colour(0), linewidths=0.0, alpha=self.POINT_ALPHA) def _sizes(self, rows: pd.DataFrame) -> np.ndarray: """One marker size per point, from the size channel if bound. :param rows: the rows being plotted. :returns: the sizes. """ spec = self._spec base = np.full(len(rows), float(self.POINT_SIZE_BASE)) limits = getattr(self._scales, "size_limits", None) if not spec.size or spec.size not in rows.columns or not limits: return base values = pd.to_numeric(rows[spec.size], errors="coerce").to_numpy(float) low, high = limits span = (high - low) or 1.0 scaled = np.clip((values - low) / span, 0.0, 1.0) scaled = np.where(np.isfinite(scaled), scaled, 0.0) return 10.0 + scaled * 130.0 def _draw_panel(self, ax, rows, mask, kind, data, palette ) -> Optional[Callable]: """Draw one panel, then decorate it. `decorate_axes` runs AFTER the data because it reads the limits: a log scale can only be applied where the data is positive, and before anything is plotted the limits are matplotlib's default (0, 1), which makes every axis look inapplicable. """ updater = self._draw_panel_marks(ax, rows, mask, kind, data, palette) self.decorate_axes(ax) return updater def _draw_panel_marks(self, ax, rows, mask, kind, data, palette ) -> Optional[Callable]: """Draw one panel; return an updater for a cheap highlight repaint. The updater exists only for point marks, where a selection change is a change of two artists. Aggregates redraw — their overlay is a recomputed reduction, not a re-styled artist. """ if rows.empty: ax.text(0.5, 0.5, "no rows", ha="center", va="center", color=palette["fg_muted"], fontsize=8, transform=ax.transAxes) return None if kind == SCATTER and data.strategy == BINNED: self._draw_density(ax, rows, palette) return None if kind in (SCATTER, LINE): return self._draw_points(ax, rows, mask, kind, palette) if kind == HISTOGRAM: self._draw_histogram(ax, rows, mask, palette) return None if kind in (BAR, BAR_JITTER): self._draw_bar(ax, rows, mask, palette) if kind == BAR_JITTER: self._draw_jitter(ax, rows, palette, over_bars=True) return None if kind == JITTER: self._draw_jitter(ax, rows, palette, over_bars=False) return None if kind in (BOX, VIOLIN): self._draw_distribution(ax, rows, kind, palette) return None if kind == HEATMAP: self._draw_heatmap(ax, rows, palette) return None return None def _xy(self, rows: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]: """The x and y arrays for the bound channels. :param rows: the rows being plotted. :returns: the two arrays. """ spec, scales = self._spec, self._scales def axis(column, levels): """One axis's values and its level order, or empty when unset.""" if not column or column not in rows.columns: return np.zeros(len(rows)) if levels: position = {level: i for i, level in enumerate(levels)} return np.array([position.get(str(v), np.nan) for v in rows[column].astype(str)], dtype=float) return pd.to_numeric(rows[column], errors="coerce").to_numpy(float) return (axis(spec.x, scales.x_levels), axis(spec.y, scales.y_levels)) #: Marker area for a point with no size column, and the alpha points are #: drawn at. Class attributes rather than literals inside `_draw_points` #: so a subclass whose user can set them -- the Gate Editor -- can, without #: reimplementing the drawing. POINT_SIZE_BASE = 16.0 POINT_ALPHA = 0.7
[docs] def point_colormap(self): """The colour map for a continuous colour axis. A hook: the Gate Editor lets the user choose one, and the choice has to reach the drawing rather than only the settings dict. """ return _colormap()
[docs] def decorate_axes(self, ax) -> None: """Called after each panel is drawn. Nothing by default. Where a subclass puts grid lines and log scales -- after the data, so it cannot change what was plotted, only how it is read. :param ax: the Matplotlib axes of the panel that was just drawn. """
def _draw_points(self, ax, rows, mask, kind, palette) -> Callable: """Draw the data as a scatter. :param ax: the axes to draw into. :param rows: the rows to plot. :param mask: which of them survive the filter. :param kind: the chart kind. :param palette: the colours to use. """ spec = self._spec x, y = self._xy(rows) if kind == LINE: order = np.argsort(x, kind="stable") ax.plot(x[order], y[order], color=self._series_colour(0), linewidth=2.0, solid_capstyle="round") return lambda _m: None if spec.colour and self._scales.colour_levels: colours, _levels = self._level_colours(rows[spec.colour]) base = ax.scatter(x, y, s=self._sizes(rows), c=list(colours), linewidths=0.0, alpha=self.POINT_ALPHA) elif spec.colour and self._scales.colour_limits: values = pd.to_numeric(rows[spec.colour], errors="coerce").to_numpy(float) low, high = self._scales.colour_limits base = ax.scatter(x, y, s=self._sizes(rows), c=values, cmap=self.point_colormap(), vmin=low, vmax=high, linewidths=0.0, alpha=self.POINT_ALPHA) else: base = self._draw_plain_points(ax, x, y, rows, palette) ring = ax.scatter([], [], s=54, facecolors="none", edgecolors=palette["fg"], linewidths=1.4, zorder=5) def update(new_mask) -> None: """Redraw the points for a new selection mask.""" if new_mask is None: base.set_alpha(self.POINT_ALPHA) ring.set_offsets(np.empty((0, 2))) return base.set_alpha(DIMMED_ALPHA) picked = np.column_stack([x[new_mask], y[new_mask]]) \ if new_mask.any() else np.empty((0, 2)) ring.set_offsets(picked) update(mask) return update @staticmethod def _limits_for(scale: str, limits): """Padded limits, made legal for the scale they are being set on. The padding is symmetric in data units, so a measurement starting at 1 gets a lower limit below zero. matplotlib REFUSES that on a log axis and warns, leaving the axis auto-scaled -- so the padding silently stopped applying on exactly the axes a user had configured. """ low, high = limits if scale != "log": return low, high if high <= 0: return limits floor = high / 1e6 return (max(low, floor) if low <= 0 else low), high def _draw_density(self, ax, rows, palette) -> None: """A 2-D histogram raster: every row counted, none drawn twice.""" spec, scales = self._spec, self._scales 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) x, y = x[finite], y[finite] edges_x = scales.x_edges if scales.x_edges is not None else spec.bins edges_y = scales.y_edges if scales.y_edges is not None else spec.bins counts, ex, ey = np.histogram2d(x, y, bins=[edges_x, edges_y]) weighted = None if spec.colour and scales.colour_limits: values = pd.to_numeric(rows[spec.colour], errors="coerce").to_numpy(float)[finite] total, _, _ = np.histogram2d(x, y, bins=[edges_x, edges_y], weights=np.nan_to_num(values)) with np.errstate(invalid="ignore", divide="ignore"): weighted = np.where(counts > 0, total / counts, np.nan) image = weighted if weighted is not None else np.where(counts > 0, counts, np.nan) ax.imshow(image.T, origin="lower", aspect="auto", cmap=self.point_colormap(), extent=(ex[0], ex[-1], ey[0], ey[-1]), interpolation="nearest") def _draw_histogram(self, ax, rows, mask, palette) -> None: """Draw the data as a histogram. :param ax: the axes to draw into. :param rows: the rows to plot. :param mask: which of them survive the filter. :param palette: the colours to use. """ spec, scales = self._spec, self._scales column = spec.x or spec.y values = pd.to_numeric(rows[column], errors="coerce").to_numpy(float) finite = np.isfinite(values) edges = scales.x_edges if scales.x_edges is not None else spec.bins if spec.colour and scales.colour_levels: colours, levels = self._level_colours(rows[spec.colour]) bottom = None for i, level in enumerate(levels): pick = finite & (colours == self._series_colour(i)) counts, ex = np.histogram(values[pick], bins=edges) centres = (ex[:-1] + ex[1:]) / 2.0 width = np.diff(ex) * 0.92 ax.bar(centres, counts, width=width, bottom=bottom, color=self._series_colour(i), linewidth=0.0, label=level) bottom = counts if bottom is None else bottom + counts else: counts, ex = np.histogram(values[finite], bins=edges) centres = (ex[:-1] + ex[1:]) / 2.0 ax.bar(centres, counts, width=np.diff(ex) * 0.92, color=self._series_colour(0), linewidth=0.0) if mask is not None and mask.any(): counts, ex = np.histogram(values[finite & mask], bins=edges) centres = (ex[:-1] + ex[1:]) / 2.0 ax.bar(centres, counts, width=np.diff(ex) * 0.5, color=palette["fg"], alpha=0.85, linewidth=0.0, label="selected") def _draw_bar(self, ax, rows, mask, palette) -> None: """Draw the data as bars. :param ax: the axes to draw into. :param rows: the rows to plot. :param mask: which of them survive the filter. :param palette: the colours to use. """ spec, scales = self._spec, self._scales column = spec.x or spec.y levels = list(scales.x_levels or scales.y_levels or ()) text = rows[column].astype(str).mask(rows[column].isna(), MISSING_LEVEL) other = spec.y if column == spec.x else spec.x numeric = None if other and other in rows.columns: candidate = pd.to_numeric(rows[other], errors="coerce") if candidate.notna().any(): numeric = candidate if numeric is not None: self._draw_mean_bar(ax, text, numeric, levels, palette, other) return counts = text.value_counts() heights = [float(counts.get(level, 0)) for level in levels] ax.bar(range(len(levels)), heights, width=0.78, color=self._series_colour(0), linewidth=0.0) if mask is not None and mask.any(): picked = text[mask].value_counts() ax.bar(range(len(levels)), [float(picked.get(level, 0)) for level in levels], width=0.42, color=palette["fg"], alpha=0.85, linewidth=0.0, label="selected") def _draw_mean_bar(self, ax, text, numeric, levels, palette, column) -> None: """A bar per level at the mean, with the whisker the user chose. THE AXIS SAYS WHICH WHISKER IT IS. SD and SEM differ by sqrt(n) -- fifty-five-fold at n=3000 -- so a reader who assumes the wrong one reads a real effect as noise or noise as a real effect. Putting the answer only in the settings dialog leaves it where the reader is not. """ from ...figures.spread import (SPREAD_NONE, spread_label, summarise) kind = str(getattr(self._spec, "spread", SPREAD_NONE) or SPREAD_NONE) groups = {level: numeric[(text == level).to_numpy()].dropna() for level in levels} summary = summarise(groups, kind) heights = [summary.get(level, {}).get("mean", float("nan")) for level in levels] ax.bar(range(len(levels)), heights, width=0.78, color=self._series_colour(0), linewidth=0.0) if kind != SPREAD_NONE: errors = [summary.get(level, {}).get("spread", float("nan")) for level in levels] ax.errorbar(range(len(levels)), heights, yerr=errors, fmt="none", ecolor=palette["fg"], elinewidth=1.2, capsize=4, capthick=1.2) ax.set_ylabel(spread_label(kind, unit=str(column or ""))) elif column: ax.set_ylabel(str(column)) def _draw_jitter(self, ax, rows, palette, *, over_bars: bool) -> None: """Every observation, displaced sideways so they can be told apart. THE DISPLACEMENT CARRIES NO INFORMATION. A scatter puts a point at its own x; this puts every point of a category at that category's position and spreads it only so the points are distinguishable. A reader must not be able to read the x offset as a measurement, which is why the spread is uniform and narrow rather than, say, proportional to anything. SEEDED. The offsets come from the spec's own seed, so the same data redraws identically -- a plot whose points move every time it is repainted cannot be compared with the one in a slide from last week. """ spec, scales = self._spec, self._scales if scales is None: return categorical_on_x = bool(scales.x_levels) cat_column = spec.x if categorical_on_x else spec.y num_column = spec.y if categorical_on_x else spec.x if not cat_column or not num_column or num_column not in rows.columns: return levels = list((scales.x_levels if categorical_on_x else scales.y_levels) or ()) if not levels: return text = rows[cat_column].astype(str).mask( rows[cat_column].isna(), MISSING_LEVEL) values = pd.to_numeric(rows[num_column], errors="coerce") rng = np.random.default_rng(int(getattr(spec, "seed", 0))) for index, level in enumerate(levels): picked = values[(text == level).to_numpy()].dropna().to_numpy( float) if not picked.size: continue offsets = index + rng.uniform(-0.22, 0.22, picked.size) colour = palette["fg"] if over_bars else self._series_colour(0) ax.scatter(offsets if categorical_on_x else picked, picked if categorical_on_x else offsets, s=6.0 if over_bars else 10.0, c=colour, alpha=0.55 if over_bars else 0.75, linewidths=0.0, zorder=3) def _draw_distribution(self, ax, rows, kind, palette) -> None: """Draw the data as a distribution. :param ax: the axes to draw into. :param rows: the rows to plot. :param kind: the chart kind. :param palette: the colours to use. """ spec, scales = self._spec, self._scales categorical_on_x = bool(scales.x_levels) cat_column = spec.x if categorical_on_x else spec.y num_column = spec.y if categorical_on_x else spec.x levels = list((scales.x_levels if categorical_on_x else scales.y_levels) or ()) text = rows[cat_column].astype(str).mask(rows[cat_column].isna(), MISSING_LEVEL) values = pd.to_numeric(rows[num_column], errors="coerce") groups, positions = [], [] for i, level in enumerate(levels): picked = values[(text == level).to_numpy()].dropna().to_numpy(float) if picked.size: groups.append(picked) positions.append(i) if not groups: return if kind == VIOLIN: parts = ax.violinplot(groups, positions=positions, showmedians=True, widths=0.7, **_orientation(categorical_on_x)) for body in parts["bodies"]: body.set_facecolor(self._series_colour(0)) body.set_alpha(0.55) for key in ("cbars", "cmins", "cmaxes", "cmedians"): if key in parts: parts[key].set_color(palette["fg_muted"]) else: drawn = ax.boxplot( groups, positions=positions, widths=0.6, patch_artist=True, manage_ticks=False, **_orientation(categorical_on_x), medianprops={"color": palette["fg"], "linewidth": 1.4}, flierprops={"marker": ".", "markersize": 2.5, "markerfacecolor": palette["fg_muted"], "markeredgecolor": "none", "alpha": 0.5}) for box in drawn["boxes"]: box.set_facecolor(self._series_colour(0)) box.set_alpha(0.7) box.set_linewidth(0.0) for key in ("whiskers", "caps"): for line in drawn[key]: line.set_color(palette["fg_muted"]) line.set_linewidth(0.9) def _draw_heatmap(self, ax, rows, palette) -> None: """Draw the data as a heatmap. :param ax: the axes to draw into. :param rows: the rows to plot. :param palette: the colours to use. """ spec, scales = self._spec, self._scales x_levels = list(scales.x_levels or ()) y_levels = list(scales.y_levels or ()) table = pd.crosstab( rows[spec.y].astype(str).mask(rows[spec.y].isna(), MISSING_LEVEL), rows[spec.x].astype(str).mask(rows[spec.x].isna(), MISSING_LEVEL)) table = table.reindex(index=y_levels, columns=x_levels, fill_value=0) counts = table.to_numpy(dtype=float) ax.imshow(np.where(counts > 0, counts, np.nan), origin="lower", aspect="auto", cmap=_colormap(), extent=(-0.5, len(x_levels) - 0.5, -0.5, len(y_levels) - 0.5), interpolation="nearest") def _apply_scales(self, ax, kind, scales, panel) -> None: """Give every panel the *same* limits, ticks and orders. Set explicitly from :func:`~spacr.qt.widgets.graph_spec.scales_for` rather than left to matplotlib's ``sharex``: sharing makes the panels agree with *each other*, but they agree on whatever the first panel happened to autoscale to, which is not necessarily wide enough for the rest. Computing the limits over the whole frame is what makes them bound every panel. """ spec = self._spec counts_on_y = kind in (HISTOGRAM, BAR, BAR_JITTER) if scales.x_levels is not None: ax.set_xticks(range(len(scales.x_levels))) ax.set_xticklabels(scales.x_levels, rotation=30, ha="right", fontsize=7) if spec.shared_x: ax.set_xlim(-0.6, len(scales.x_levels) - 0.4) elif spec.shared_x and scales.x_limits is not None: ax.set_xlim(*self._limits_for(ax.get_xscale(), scales.x_limits)) if counts_on_y: if spec.shared_y and scales.count_limit: ax.set_ylim(0, scales.count_limit) elif scales.y_levels is not None: ax.set_yticks(range(len(scales.y_levels))) ax.set_yticklabels(scales.y_levels, fontsize=7) if spec.shared_y: ax.set_ylim(-0.6, len(scales.y_levels) - 0.4) elif spec.shared_y and scales.y_limits is not None: ax.set_ylim(*self._limits_for(ax.get_yscale(), scales.y_limits)) def _label_panel(self, ax, panel, grid, nrows, ncols, palette) -> None: """Axis names on the outside edges only — the shared-axis convention. The value column is named on x and the count on y for a histogram or bar chart whichever zone it was dropped in, which is the same indirection :func:`~spacr.qt.widgets.graph_spec.value_axes` applies to the scales. Labelling from ``spec.x``/``spec.y`` directly would put the name on the axis the data is not on. """ spec = self._spec kind = spec.resolved_kind(self._kinds) x_column, y_column = value_axes(spec, self._kinds) counts_on_y = kind in (HISTOGRAM, BAR, BAR_JITTER) y_label = "count" if counts_on_y else (y_column or "") if kind in (BAR, BAR_JITTER) and y_column: from ...figures.spread import SPREAD_NONE, spread_label spread = str(getattr(spec, "spread", SPREAD_NONE) or SPREAD_NONE) y_label = (spread_label(spread, unit=str(y_column)) if spread != SPREAD_NONE else str(y_column)) if panel.row == nrows - 1: ax.set_xlabel(x_column or "", color=palette["fg_dim"], fontsize=9) if panel.col == 0: ax.set_ylabel(y_label, color=palette["fg_dim"], fontsize=9) if grid.is_faceted: title = panel.title() if title: ax.set_title(f"{title} · n={panel.n:,}", color=palette["fg_dim"], fontsize=8, pad=3) elif panel.n: ax.set_title(f"n={panel.n:,}", color=palette["fg_muted"], fontsize=8, pad=3, loc="right") def _draw_legend(self, kind, palette) -> None: """A legend whenever there are two or more series — identity is never colour alone.""" levels = getattr(self._scales, "colour_levels", None) if not levels or len(levels) < 2: return from matplotlib.lines import Line2D handles = [Line2D([], [], marker="o", linestyle="none", markersize=6, markerfacecolor=self._series_colour(i), markeredgecolor="none", label=str(level)) for i, level in enumerate(levels[:len(categorical_colours())])] if len(levels) > len(categorical_colours()): handles.append(Line2D([], [], marker="o", linestyle="none", markersize=6, markerfacecolor=OTHER_COLOUR, markeredgecolor="none", label=f"{OTHER_LABEL} " f"({len(levels) - len(categorical_colours())})")) legend = self._figure.legend( handles=handles, loc="upper right", frameon=False, fontsize=8, title=self._spec.colour, title_fontsize=8) for text in legend.get_texts(): text.set_color(palette["fg_dim"]) if legend.get_title() is not None: legend.get_title().set_color(palette["fg_muted"]) def _notice_text(self, data: RenderData, grid) -> str: """The sentence explaining why the chart looks the way it does. SAYS WHAT WAS DONE TO THE DATA -- sampled, binned, or clipped -- so a reader does not take a thinned scatter for the whole set. :returns: the notice, or ``""`` when nothing was done. """ parts = [data.notice] if grid.notice: parts.append(grid.notice) if not self._keyed: parts.append("no object keys in this table — brushing cannot " "publish a selection") if self._filter_note: parts.append(self._filter_note.strip(" ·")) if self._selected_mask is not None: parts.append(f"{int(self._selected_mask.sum()):,} highlighted") return " · ".join(p for p in parts if p)
[docs] def on_linked_filter_changed(self, data_filter) -> None: """A filter genuinely narrows the population: redraw and re-scale. :param data_filter: the linked filter that changed; it is not read here, only a debounced redraw is started. """ self._debounce.start()
[docs] def on_linked_selection_changed(self, selection: Selection) -> None: """A selection only highlights — never a row fewer on screen. :param selection: the linked selection that changed; it is not read directly, and the highlight is recomputed from the current linked selection. """ if self._render_data is None: return if not self._live_highlight: self.render_now() return self._selected_mask = self._selection_mask(self._render_data.frame) for (row, col), overlay in self._overlays.items(): if overlay is None: continue panel = self._grid.panel(row, col) mask = (self._selected_mask[panel.index] if self._selected_mask is not None else None) overlay(mask) self._canvas.draw_idle() self._notice.setText(self._notice_text(self._render_data, self._grid))
[docs] def brush(self, x0: float, y0: float, x1: float, y1: float, *, row: int = 0, col: int = 0, publish: bool = True) -> Optional[Selection]: """Select every row of one panel inside the rectangle, and publish it. Evaluated against the panel's **unsampled** rows, so a brush over a density raster or a sampled scatter still names every row in the rectangle rather than only the ones that got drawn. :param x0: horizontal start of the rectangle in the panel's data coordinates; on a categorical axis these are tick positions, one per level, and the two ends may come in either order. :param y0: vertical start of the rectangle, in the same coordinates. :param x1: horizontal end of the rectangle. :param y1: vertical end of the rectangle; ignored on a histogram or bar chart, whose vertical axis is a count. :returns: the published :class:`~spacr.selection.Selection`, or ``None`` when this table carries no object keys to name rows with. """ if (self._visible is None or self._brush_grid is None or not self._keyed): return None try: panel = self._brush_grid.panel(row, col) except IndexError: return None rows = panel.frame(self._visible) keep = brush_mask(rows, self._spec, self._kinds, x0, y0, x1, y1, self._scales) picked = rows.loc[keep] if not publish: return Selection.from_frame(picked, source=self.link_source) return self.publish_selection(picked)
def _on_press(self, event) -> None: """Begin a rubber-band selection. :param event: the matplotlib press event. """ if event.inaxes is None or event.xdata is None: return self._drag_origin = (event.inaxes, float(event.xdata), float(event.ydata)) def _on_motion(self, event) -> None: """Grow the rubber band. :param event: the matplotlib motion event. """ if self._drag_origin is None or event.inaxes is not self._drag_origin[0]: return if event.xdata is None or event.ydata is None: return ax, x0, y0 = self._drag_origin if self._drag_patch is None: self._drag_patch = self._make_drag_patch(x0, y0) ax.add_patch(self._drag_patch) self._update_drag_patch(self._drag_patch, x0, y0, float(event.xdata), float(event.ydata)) self._canvas.draw_idle() def _drag_patch_style(self) -> dict: """How the rubber band is drawn. :returns: the patch keyword arguments. """ palette = active_palette() return {"facecolor": palette["accent"], "alpha": 0.18, "edgecolor": palette["accent"], "linewidth": 1.0, "zorder": 6} def _make_drag_patch(self, x0: float, y0: float): """The patch previewing a sweep. A rectangle unless overridden.""" from matplotlib.patches import Rectangle return Rectangle((x0, y0), 0, 0, **self._drag_patch_style()) def _update_drag_patch(self, patch, x0: float, y0: float, x1: float, y1: float) -> None: """Resize the preview to the sweep so far.""" patch.set_bounds(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)) def _on_release(self, event) -> None: """Finish the selection and broadcast it to the linked views. :param event: the matplotlib release event. """ 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 ax, x0, y0 = origin if event.xdata is None or event.ydata is None: return where = self._axes_at.get(id(ax)) if where is None: return span_x = abs(float(event.xdata) - x0) span_y = abs(float(event.ydata) - y0) width = abs(np.diff(ax.get_xlim())[0]) or 1.0 height = abs(np.diff(ax.get_ylim())[0]) or 1.0 if span_x < width * 0.01 and span_y < height * 0.01: self.clear_linked_selection() return self.brush(x0, y0, float(event.xdata), float(event.ydata), row=where[0], col=where[1])
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Unlink from the shared selection before going away. A LINKED VIEW THAT OUTLIVES ITS WINDOW is a selection broadcast to a widget whose C++ half is gone, which is a crash rather than a leak. :param event: the Qt close event. """ try: self.unlink_selection() except (RuntimeError, TypeError): pass self._debounce.stop() canvas = getattr(self, "_canvas", None) if canvas is not None and hasattr(canvas, "cancel_pending_draw"): canvas.cancel_pending_draw() super().closeEvent(event)
[docs] class GraphBuilderPanel(QWidget): """The well, the six zones, the plot-type override and the 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. """ spec_changed = Signal(object) def __init__(self, parent=None, *, link=None, source: str = "graph_builder", fold_key: str = ""): """Build the well, the drop zones and the canvas. :param parent: parent widget. :param fold_key: the host module's key. Given, the width the user drags the shelf | graph split to is remembered under ``"<fold_key>::graph"`` and a fold of the Columns shelf or the Graph under ``"<fold_key>/Columns"`` and ``"<fold_key>/Graph"``; empty remembers nothing, which is what a test or a second host that has not chosen a key wants. """ super().__init__(parent) self.setObjectName("GraphBuilderPanel") self._zones: Dict[str, DropZone] = {} self._building = False outer = QHBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["sm"]) from .collapsible_splitter import CollapsibleSplitter key = str(fold_key or "") splitter = CollapsibleSplitter( Qt.Horizontal, self, persist_key=f"{key}::graph" if key else "") self.splitter = splitter outer.addWidget(splitter, 1) shelf = QWidget(self) shelf.setObjectName("GraphShelf") shelf_layout = QVBoxLayout(shelf) shelf_layout.setContentsMargins(SPACING["sm"], SPACING["sm"], SPACING["sm"], SPACING["sm"]) shelf_layout.setSpacing(SPACING["sm"]) self.well = ColumnWell(shelf) shelf_layout.addWidget(self.well, 1) zones = QGridLayout() zones.setContentsMargins(0, 0, 0, 0) zones.setSpacing(SPACING["xs"]) for i, channel in enumerate(CHANNELS): zone = DropZone(channel, shelf) zone.column_changed.connect(self._on_zone_changed) self._zones[channel] = zone zones.addWidget(zone, i // 2, i % 2) shelf_layout.addLayout(zones) controls = QGridLayout() controls.setContentsMargins(0, 0, 0, 0) controls.setSpacing(SPACING["xs"]) self._kind = QComboBox(shelf) self._kind.setObjectName("GraphKindPicker") self._kind.addItem("Automatic", "") for kind in PLOT_KINDS: if kind != EMPTY: self._kind.addItem(kind.capitalize(), kind) self._kind.setToolTip( "The plot type is inferred from the columns dropped. Pick one " "here to pin it instead.") self._kind.currentIndexChanged.connect(self._on_controls_changed) controls.addWidget(QLabel("Plot", shelf), 0, 0) controls.addWidget(self._kind, 0, 1) self._bins = QSpinBox(shelf) self._bins.setRange(2, 200) self._bins.setValue(30) self._bins.setToolTip("Bins per axis, for histograms and the density " "raster.") self._bins.valueChanged.connect(self._on_controls_changed) controls.addWidget(QLabel("Bins", shelf), 1, 0) controls.addWidget(self._bins, 1, 1) self._shared_x = Toggle("Shared X", shelf) self._shared_y = Toggle("Shared Y", shelf) for box in (self._shared_x, self._shared_y): box.setChecked(True) box.setToolTip("Every panel on the same scale. Off makes panels " "incomparable — which is occasionally what you want.") box.toggled.connect(self._on_controls_changed) controls.addWidget(self._shared_x, 2, 0) controls.addWidget(self._shared_y, 2, 1) shelf_layout.addLayout(controls) self._clear = QPushButton("Clear all channels", shelf) self._clear.setObjectName("GraphClearButton") self._clear.clicked.connect(self.clear_channels) shelf_layout.addWidget(self._clear) self.shelf_section = splitter.add_section( shelf, "Columns", persist_key=f"{key}/Columns" if key else "", stretch=0, extent=300) self.canvas = GraphCanvas(self, link=link, source=source) self.canvas_section = splitter.add_section( self.canvas, "Graph", persist_key=f"{key}/Graph" if key else "", stretch=1, extent=900) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self)
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Point the whole panel at a new table: the well and the canvas both. :param frame: the table to plot, or None to clear. """ self.well.set_frame(frame) self.canvas.set_frame(frame) self._sync_zones()
@property
[docs] def spec(self) -> GraphSpec: """The graph the canvas is drawing. :returns: the spec. """ return self.canvas.spec
[docs] def set_spec(self, spec: GraphSpec) -> None: """Push a whole spec in — restoring a saved chart, or a preset. :param spec: the complete chart specification; the canvas redraws and the drop zones are updated to match it. """ self.canvas.set_spec(spec) self._sync_zones()
[docs] def clear_channels(self) -> None: """Empty every drop zone, leaving the table loaded.""" for zone in self._zones.values(): zone.set_column(None)
[docs] def zone(self, channel: str) -> DropZone: """One channel's drop zone, for a caller that needs to drive it. :param channel: the channel's name. :returns: the zone widget, or None when there is no such channel. """ return self._zones[channel]
def _sync_zones(self) -> None: """Make the zones and the controls show what the spec actually says.""" self._building = True try: spec = self.canvas.spec for channel, zone in self._zones.items(): zone.set_column(spec.column_for(channel)) index = self._kind.findData(spec.kind or "") if index >= 0: self._kind.setCurrentIndex(index) self._bins.setValue(spec.bins) self._shared_x.setChecked(spec.shared_x) self._shared_y.setChecked(spec.shared_y) finally: self._building = False def _on_zone_changed(self, channel: str, column: str) -> None: """Rebind a channel and redraw. :param channel: the channel that changed. :param column: the column now bound, or ``""`` to clear it. """ if self._building: return self.canvas.set_channel(channel, column or None) self.spec_changed.emit(self.canvas.spec) def _on_controls_changed(self, *_args) -> None: """Redraw after a plot-type or scale control moved.""" if self._building: return from dataclasses import replace as _replace spec = _replace( self.canvas.spec, kind=(self._kind.currentData() or None), bins=self._bins.value(), shared_x=self._shared_x.isChecked(), shared_y=self._shared_y.isChecked()) self.canvas.set_spec(spec, immediate=False) self.spec_changed.emit(spec)
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Stop background work and unlink before going away. :param event: the Qt close event. """ self.canvas.close() super().closeEvent(event)
def _graph_builder_qss(palette, opacity) -> str: """Build the graph builder's stylesheet. The panel and canvas objects are made transparent explicitly: they hold a splitter edge to edge and the canvas paints the page surface itself, so without a rule they take the blanket ``QWidget`` background -- the WINDOW colour, not a surface, which no page-opacity setting can reach. :param palette: the active palette. :param opacity: the page opacity, blended into the shelf's surface. :returns: the QSS. """ from ..theme import block_surface surface_alt = block_surface("surface_alt", palette["theme"], opacity) return f""" QWidget#GraphShelf, QWidget#TrellisShelf {{ background: {surface_alt}; border-radius: {RADIUS["md"]}px; }} /* Scaffolding, whatever it is called: the panels hold a splitter edge to * edge and the canvas below paints the page surface. Without this they * take the blanket `QWidget` rule, which is the WINDOW colour and not a * surface, so no page-opacity setting could ever reach them. */ QWidget#GraphBuilderPanel, QWidget#TrellisPanel, QWidget#GraphCanvas, QWidget#PCAPanel, QWidget#GateEditorPanel, QWidget#FeatureExplorerPanel {{ background: transparent; }} QFrame#GraphDropZone {{ background: transparent; border: 1px dashed {palette["border"]}; border-radius: {RADIUS["sm"]}px; min-height: 34px; }} QFrame#GraphDropZone[filled="true"] {{ border: 1px solid {palette["accent"]}; background: {palette["accent_soft"]}; }} QFrame#GraphDropZone[hovered="true"] {{ border: 1px solid {palette["accent_hi"]}; }} QLabel#GraphDropZoneName {{ color: {palette["fg_muted"]}; font-weight: 600; }} QLabel#GraphDropZoneValue {{ color: {palette["fg"]}; }} QFrame#GraphDropZone[filled="false"] QLabel#GraphDropZoneValue {{ color: {palette["fg_muted"]}; font-style: italic; }} QLabel#GraphNotice, QLabel#GraphColumnCount {{ color: {palette["fg_muted"]}; font-size: {font_px(11)}px; }} QListWidget#GraphColumnList {{ background: transparent; border: 1px solid {palette["border_soft"]}; border-radius: {RADIUS["sm"]}px; }} """ register_widget_qss("GraphBuilder", _graph_builder_qss, replace=True)