"""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
@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)