"""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. 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 of the grid
---------------------------
**Inner tick labels are hidden only when the axis is genuinely shared.** A
trellis with per-panel scales that hid its inner ticks would misstate the
scales; 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)
try:
self._figure.tight_layout(pad=0.7)
except (ValueError, np.linalg.LinAlgError):
pass
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))