"""The trellis surface — the Graph Builder's canvas, laid out as a grid.
:class:`TrellisCanvas` **subclasses**
:class:`spacr.qt.widgets.graph_builder.GraphCanvas` rather than reimplementing
it. That is the whole design decision in this file. The mark drawing, the fixed
categorical hue order, the density raster, the selection ring, the legend and
the brush are one implementation, and a second one would be a second set of
rules to keep in step with the first — the mistake
:mod:`spacr.qt.widgets.pivot_builder` avoids by not drawing anything at all.
What the subclass changes is the two things a trellis is:
* **the layout** comes from :func:`spacr.qt.widgets.trellis_spec.trellis`,
which knows about wrapping and about blank slots, rather than from
``facet_grid`` directly;
* **the scales are set per panel from that panel's own group**. Every panel's
limits are written explicitly rather than left to matplotlib's ``sharex``:
sharing makes panels agree with *each other*, but on whatever the first one
autoscaled to, which is not necessarily wide enough for the rest. Under
:data:`~spacr.qt.widgets.trellis_spec.SCALE_SHARED` — the default — every
panel is handed the identical tuple, which is a property a test can assert
and an eye can trust.
Two conventions the grid earns
------------------------------
**Inner tick labels are hidden only when the axis is genuinely shared.** A
trellis with per-panel scales that hides its inner ticks is a lie with a
tidy layout; if a panel has its own limits it prints its own numbers.
**Every panel's title carries its n**, from
:meth:`~spacr.qt.widgets.trellis_spec.TrellisPanel.label`. Blank slots at the
end of a wrapped grid are hidden entirely — they are not panels with no data,
they are the remainder of a division.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from typing import Dict, Optional
import numpy as np
import pandas as pd
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QGridLayout, QHBoxLayout, QLabel, QPushButton,
QSpinBox, QVBoxLayout, QWidget,
)
from ...selection import Selection
from ..theme import SPACING, active_palette
from .collapsible_splitter import CollapsibleSplitter
from .graph_builder import ColumnWell, DropZone, GraphCanvas
from .graph_spec import (
BAR, BINNED, CHANNELS, EMPTY, FACET_COL, FACET_ROW, HISTOGRAM, PLOT_KINDS,
SCATTER, GraphSpec, value_axes,
)
from .trellis_spec import (
MAX_WRAP, SCALE_LABELS, SCALE_MODES, SCALE_SHARED, Trellis, TrellisSpec,
trellis,
)
LOG = logging.getLogger("spacr.qt.trellis")
__all__ = ["TrellisCanvas", "TrellisPanelWidget"]
[docs]
class TrellisCanvas(GraphCanvas):
"""A grid of the same chart, one panel per group, on shared axes.
Everything :class:`~spacr.qt.widgets.graph_builder.GraphCanvas` does —
linked selection, brushing, the large-data policy, the colour order — with
the layout and the scales taken from
:class:`~spacr.qt.widgets.trellis_spec.TrellisSpec`.
: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.
"""
#: Emitted after every render with the :class:`Trellis` that was drawn.
trellis_rendered = Signal(object)
def __init__(self, parent=None, *, link=None, source: str = "trellis"):
"""Create the trellis canvas with an empty spec.
The spec is set after the base constructor, which builds the figure and
subscribes to the link but does not render -- so nothing reads these
before they exist.
:param parent: parent widget, or ``None``.
:param link: shared selection link.
:param source: this view's name in the link, so its own publications can
be told from everyone else's.
"""
super().__init__(parent, link=link, source=source)
self._trellis_spec = TrellisSpec()
self._trellis: Optional[Trellis] = None
@property
[docs]
def trellis_spec(self) -> TrellisSpec:
"""The grid this canvas is drawing.
:returns: the trellis spec.
"""
return self._trellis_spec
@property
[docs]
def trellis(self) -> Optional[Trellis]:
"""The last computed grid, or ``None`` before the first render."""
return self._trellis
[docs]
def set_trellis_spec(self, spec: TrellisSpec, *,
immediate: bool = True) -> None:
"""Replace the whole spec and redraw.
:param spec: the complete trellis spec: grid options plus the inner
chart spec in its ``graph`` attribute.
"""
self._trellis_spec = spec
self._spec = spec.graph
if self._frame is not None:
self._kinds = spec.graph.kinds_for(self._frame)
if immediate:
self.render_now()
else:
self._debounce.start()
[docs]
def set_spec(self, spec: GraphSpec, *, immediate: bool = True) -> None:
"""Replace only the inner chart spec, keeping the grid's own options.
:param spec: the inner chart spec, combined with the current grid
options through ``TrellisSpec.with_graph``.
"""
self.set_trellis_spec(self._trellis_spec.with_graph(spec),
immediate=immediate)
[docs]
def set_channel(self, channel: str, column: Optional[str]) -> None:
"""Rebind one of the graph's channels and redraw.
:param channel: the channel's name, such as ``x`` or ``facet_row``.
:param column: the column to bind, or None to clear it.
"""
self.set_spec(self._spec.with_channel(channel, column))
[docs]
def render_now(self) -> None:
"""Rebuild the grid from the frame, the spec, the filter and the
selection."""
self._debounce.stop()
self._figure.clear()
self._axes = {}
self._axes_at = {}
self._overlays = {}
self._drag_patch = None
palette = active_palette()
if self._frame is None or self._frame.empty:
self._trellis = None
self._render_message(
"Load a table, then drag a column onto X or Y and a grouping "
"column onto Facet ↓ or Facet →.")
return
self._visible, self._filter_note = self._apply_filter(self._frame)
spec = self._trellis_spec
self._spec = spec.graph
kinds = self._kinds
if spec.graph.resolved_kind(kinds) == EMPTY:
self._trellis = None
self._render_message(
"Drag a column onto X or Y to say what each panel shows,\n"
"then a grouping column onto Facet ↓ or Facet → to repeat it.")
return
result = trellis(self._visible, spec)
kind = spec.graph.resolved_kind(kinds)
self._trellis = result
self._render_data = result.data
self._grid = result.grid
self._brush_grid = result.grid
self._scales = result.shared
self._live_highlight = (kind == SCATTER
and result.data.strategy != BINNED)
self._selected_mask = self._selection_mask(result.frame)
nrows, ncols = result.shape
axes = self._figure.subplots(nrows, ncols, squeeze=False,
sharex=False, sharey=False)
for panel in result.panels:
ax = axes[panel.row][panel.col]
self._axes[(panel.row, panel.col)] = ax
self._axes_at[id(ax)] = (panel.row, panel.col)
self._overlays[(panel.row, panel.col)] = None
if not panel.occupied:
ax.set_visible(False)
continue
self._style_axes(ax, palette)
rows = panel.frame(result.frame)
mask = (self._selected_mask[panel.index]
if self._selected_mask is not None else None)
previous, self._scales = self._scales, panel.scales
try:
self._overlays[(panel.row, panel.col)] = self._draw_panel(
ax, rows, mask, kind, result.data, palette)
finally:
self._scales = previous
self._apply_panel_scales(ax, kind, panel.scales)
self._label_trellis_panel(ax, panel, result, palette)
self._draw_legend(kind, palette)
self._figure.tight_layout(pad=0.7)
self._canvas.draw_idle()
self._notice.setText(self._trellis_notice(result))
self.rendered.emit(result.data)
self.trellis_rendered.emit(result)
def _apply_panel_scales(self, ax, kind, scales) -> None:
"""Write this panel's limits, ticks and orders explicitly.
Always written, never left to an autoscale: under
:data:`~spacr.qt.widgets.trellis_spec.SCALE_SHARED` every panel is
handed the same numbers, which is what "shared" has to mean if a shift
between panels is to read as a shift.
"""
counts_on_y = kind in (HISTOGRAM, BAR)
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)
ax.set_xlim(-0.6, len(scales.x_levels) - 0.4)
elif scales.x_limits is not None:
ax.set_xlim(*scales.x_limits)
if counts_on_y:
if 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)
ax.set_ylim(-0.6, len(scales.y_levels) - 0.4)
elif scales.y_limits is not None:
ax.set_ylim(*scales.y_limits)
def _label_trellis_panel(self, ax, panel, result: Trellis, palette) -> None:
"""Title with n, axis names on the edges, ticks where they are needed."""
spec = self._trellis_spec
kind = spec.graph.resolved_kind(self._kinds)
x_column, y_column = value_axes(spec.graph, self._kinds)
counts_on_y = kind in (HISTOGRAM, BAR)
nrows, ncols = result.shape
below = (result.panel(panel.row + 1, panel.col)
if panel.row + 1 < nrows else None)
is_bottom = below is None or not below.occupied
is_left = panel.col == 0
if is_bottom:
ax.set_xlabel(x_column or "", color=palette["fg_dim"], fontsize=9)
if is_left:
ax.set_ylabel("count" if counts_on_y else (y_column or ""),
color=palette["fg_dim"], fontsize=9)
if spec.scale_x == SCALE_SHARED and not is_bottom:
ax.tick_params(labelbottom=False)
if spec.scale_y == SCALE_SHARED and not is_left:
ax.tick_params(labelleft=False)
title = panel.label()
if title:
colour = (palette["warning"] if panel.is_low_n
else palette["fg_dim"])
ax.set_title(title, color=colour, fontsize=8, pad=3)
def _trellis_notice(self, result: Trellis) -> str:
"""Compose the line under the trellis.
It states what was drawn, then anything that limits how it should be
read: a table with no object keys cannot publish a brush, an active
filter is narrowing it, and how many points are highlighted.
:param result: the computed trellis.
:returns: the notice, parts joined by a middle dot.
"""
parts = [result.summary()]
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 brush(self, x0: float, y0: float, x1: float, y1: float, *,
row: int = 0, col: int = 0,
publish: bool = True) -> Optional[Selection]:
"""Select the rows of one panel inside a swept rectangle.
Delegated to :meth:`~spacr.qt.widgets.trellis_spec.Trellis.brush`,
which evaluates the rectangle against the **unsampled** rows and
against that panel's own scales — so it is exact over a density raster
and correct under a per-panel scale, where the drawn coordinates of a
categorical axis differ from the grid's.
:param x0: horizontal position of one corner of the rectangle, in the
panel's data coordinates; the corners may be given in either order.
:param y0: vertical position of that corner, in the panel's data
coordinates; the corners may be given in either order.
:param x1: horizontal position of the opposite corner.
:param y1: vertical position of the opposite corner.
"""
if self._trellis is None or self._visible is None or not self._keyed:
return None
try:
keep = self._trellis.brush(x0, y0, x1, y1, row=row, col=col)
except IndexError:
return None
picked = self._visible.loc[keep]
if not publish:
return Selection.from_frame(picked, source=self.link_source)
return self.publish_selection(picked)
[docs]
def on_linked_selection_changed(self, selection: Selection) -> None:
"""Move the highlight; redraw only when the marks cannot be re-styled.
:param selection: the new shared selection; not read directly, since
the highlight is recomputed from the canvas's linked selection.
"""
if self._trellis is None:
return
if not self._live_highlight:
self.render_now()
return
self._selected_mask = self._selection_mask(self._trellis.frame)
for (row, col), overlay in self._overlays.items():
if overlay is None:
continue
panel = self._trellis.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._trellis_notice(self._trellis))