diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 6ac24bdcef..59ecb2a8fa 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -37,6 +37,7 @@ quartodoc: - after_stat - after_scale - stage + - I - subtitle: Functions in the Aesthetic Evaluation Environment desc: | diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 03a71f81d7..caf7a7fef5 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -104,6 +104,16 @@ title: Changelog [](`numpy.random.Generator`), in addition to an integer seed or a [](`~numpy.random.RandomState`). +- Added [](:func:`~plotnine.I`), with which you can mark a value as literal. + A literal value takes no scale, so it is not trained, not mapped, and gets + no legend. One layer can draw literal colours while another maps the same + aesthetic through a scale. ({{< issue 1008 >}}) + +- You can now place an annotation relative to the panel by wrapping a position + in [](:func:`~plotnine.I`). `x=I(0.5)` is the centre of the panel and + `x=I(0.9)` is 90% across, whatever the data, the limits or the expansion. + ({{< issue 939 >}}) + ### API Changes - Removed `geom.to_layer()`, `stat.to_layer()`, `annotate.to_layer()`, diff --git a/plotnine/__init__.py b/plotnine/__init__.py index f1dbed8405..ac7f3f5c48 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -105,6 +105,7 @@ ylab, ) from .mapping import ( + I, aes, after_scale, after_stat, @@ -287,6 +288,7 @@ ) __all__ = ( + "I", "aes", "after_scale", "after_stat", diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 20beb7930c..f4de43a69c 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -11,7 +11,7 @@ from ..mapping.aes import POSITION_AESTHETICS if typing.TYPE_CHECKING: - from typing import Any, Sequence + from typing import Any, Literal, Sequence import numpy.typing as npt import pandas as pd @@ -398,6 +398,28 @@ def backtransform_range(self, panel_params: panel_view) -> panel_ranges: """ return self.range(panel_params) + def panel_fraction_to_data( + self, + fractions: FloatArrayLike, + panel_params: panel_view, + dimension: Literal["x", "y"], + ) -> FloatArray: + """ + Convert panel fractions to data coordinates + + Parameters + ---------- + fractions : + Fractions where 0 and 1 mark the panel edges along + `dimension`. Values outside that range fall outside the panel. + panel_params : + Panel ranges and breaks. + dimension : + Data dimension represented by the fractions. + """ + lo, hi = getattr(self.backtransform_range(panel_params), dimension) + return lo + np.asarray(fractions, dtype=float) * (hi - lo) + def distance( self, x: FloatSeries, diff --git a/plotnine/coords/coord_trans.py b/plotnine/coords/coord_trans.py index 561f1df896..4cd108fd99 100644 --- a/plotnine/coords/coord_trans.py +++ b/plotnine/coords/coord_trans.py @@ -4,13 +4,15 @@ from typing import TYPE_CHECKING, cast from warnings import warn +import numpy as np + from ..exceptions import PlotnineWarning from ..iapi import panel_ranges, panel_view from ..positions.position import transform_position from .coord import coord, dist_euclidean if TYPE_CHECKING: - from typing import Optional + from typing import Literal, Optional import pandas as pd from mizani.transforms import trans @@ -19,6 +21,7 @@ from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ( FloatArray, + FloatArrayLike, FloatSeries, TFloatArrayLike, ) @@ -98,6 +101,35 @@ def backtransform_range(self, panel_params: panel_view) -> panel_ranges: y=self.trans_y.inverse(panel_params.y.range), ) + def panel_fraction_to_data( + self, + fractions: FloatArrayLike, + panel_params: panel_view, + dimension: Literal["x", "y"], + ) -> FloatArray: + """ + Convert panel fractions to data coordinates + + Measure the fraction in transformed drawing space, then invert it + before storing it in the data column that this coordinate system + transforms again during drawing. + + Parameters + ---------- + fractions : + Fractions where 0 and 1 mark the panel edges along + `dimension`. Values outside that range fall outside the + panel. + panel_params : + Panel ranges and breaks. + dimension : + Data dimension represented by the fractions. + """ + trans = self.trans_x if dimension == "x" else self.trans_y + lo, hi = getattr(panel_params, dimension).range + value = lo + np.asarray(fractions, dtype=float) * (hi - lo) + return trans.inverse(value) + def setup_panel_params(self, scale_x, scale_y) -> panel_view: """ Compute the range and break information for the panel diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 842681bd3c..52adb540bc 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -12,6 +12,7 @@ from .._mpl.axes import p9Axes from .._utils import cross_join, match from ..exceptions import PlotnineError +from ..mapping._asis import asis_columns from ..scales.scales import Scales from .strips import Strips @@ -274,10 +275,12 @@ def train_position_scales(self, layout: Layout, layers: Layers) -> facet: # loop over each layer, training x and y scales in turn for layer in layers: data = layer.data + asis = asis_columns(data) match_id = match(data["PANEL"], _layout["PANEL"]) if panel_scales_x: x_vars = list( - set(panel_scales_x[0].aesthetics) & set(data.columns) + (set(panel_scales_x[0].aesthetics) & set(data.columns)) + - asis ) # the scale index for each data point SCALE_X = _layout["SCALE_X"].iloc[match_id].tolist() @@ -285,7 +288,8 @@ def train_position_scales(self, layout: Layout, layers: Layers) -> facet: if panel_scales_y: y_vars = list( - set(panel_scales_y[0].aesthetics) & set(data.columns) + (set(panel_scales_y[0].aesthetics) & set(data.columns)) + - asis ) # the scale index for each data point SCALE_Y = _layout["SCALE_Y"].iloc[match_id].tolist() diff --git a/plotnine/facets/layout.py b/plotnine/facets/layout.py index caf5d9a1a6..28ac438306 100644 --- a/plotnine/facets/layout.py +++ b/plotnine/facets/layout.py @@ -8,6 +8,7 @@ from .._utils import match from ..exceptions import PlotnineError from ..iapi import labels_view, layout_details, pos_scales +from ..mapping._asis import asis_columns if typing.TYPE_CHECKING: import pandas as pd @@ -120,17 +121,26 @@ def map_position(self, layers: Layers): for layer in layers: data = layer.data + asis = asis_columns(data) match_id = match(data["PANEL"], _layout["PANEL"]) if self.panel_scales_x: x_vars = list( - set(self.panel_scales_x[0].aesthetics) & set(data.columns) + ( + set(self.panel_scales_x[0].aesthetics) + & set(data.columns) + ) + - asis ) SCALE_X = _layout["SCALE_X"].iloc[match_id].tolist() self.panel_scales_x.map(data, x_vars, SCALE_X) if self.panel_scales_y: y_vars = list( - set(self.panel_scales_y[0].aesthetics) & set(data.columns) + ( + set(self.panel_scales_y[0].aesthetics) + & set(data.columns) + ) + - asis ) SCALE_Y = _layout["SCALE_Y"].iloc[match_id].tolist() self.panel_scales_y.map(data, y_vars, SCALE_Y) diff --git a/plotnine/geoms/annotate.py b/plotnine/geoms/annotate.py index 72cf275c57..fcf924e939 100644 --- a/plotnine/geoms/annotate.py +++ b/plotnine/geoms/annotate.py @@ -2,6 +2,7 @@ import typing +import numpy as np import pandas as pd from .._utils import is_scalar @@ -9,12 +10,14 @@ from ..exceptions import PlotnineError from ..geoms.geom import geom as geom_base_class from ..mapping import aes +from ..mapping._asis import is_asis from ..mapping.aes import POSITION_AESTHETICS if typing.TYPE_CHECKING: from typing import Any from plotnine import ggplot + from plotnine.mapping._asis import AsIs class annotate: @@ -55,6 +58,9 @@ class annotate: You should choose or ignore accordingly. + Wrap a position aesthetic in [](:func:`~plotnine.I`) to express it as + a fraction of the panel rather than as a data coordinate. + All `geoms` are created with `stat="identity"`{.py}. """ @@ -63,16 +69,16 @@ class annotate: def __init__( self, geom: str | type[geom_base_class], - x: float | list[float] | None = None, - y: float | list[float] | None = None, - xmin: float | list[float] | None = None, - xmax: float | list[float] | None = None, - xend: float | list[float] | None = None, - xintercept: float | list[float] | None = None, - ymin: float | list[float] | None = None, - ymax: float | list[float] | None = None, - yend: float | list[float] | None = None, - yintercept: float | list[float] | None = None, + x: float | list[float] | AsIs | None = None, + y: float | list[float] | AsIs | None = None, + xmin: float | list[float] | AsIs | None = None, + xmax: float | list[float] | AsIs | None = None, + xend: float | list[float] | AsIs | None = None, + xintercept: float | list[float] | AsIs | None = None, + ymin: float | list[float] | AsIs | None = None, + ymax: float | list[float] | AsIs | None = None, + yend: float | list[float] | AsIs | None = None, + yintercept: float | list[float] | AsIs | None = None, **kwargs: Any, ): variables = locals() @@ -83,14 +89,20 @@ def __init__( for loc in POSITION_AESTHETICS if variables[loc] is not None } + # Record literal positions as expressions after removing their + # dtype tag so position scales skip them during training. + asis_aes = {ae for ae, v in pos_aesthetics.items() if is_asis(v)} + pos_aesthetics = {ae: v for ae, v in pos_aesthetics.items()} aesthetics = pos_aesthetics.copy() aesthetics.update(kwargs) - # Check if the aesthetics are of compatible lengths + # A length-one position broadcasts to match the other aesthetics. lengths, info_tokens = [], [] for ae, val in aesthetics.items(): if is_scalar(val): continue + if ae in pos_aesthetics and len(val) == 1: + continue lengths.append(len(val)) info_tokens.append((ae, len(val))) @@ -99,6 +111,13 @@ def __init__( msg = f"Unequal parameter lengths: {details}" raise PlotnineError(msg) + # Repeat a length-one position to match the longest position vector. + if lengths: + max_length = max(lengths) + for ae, val in pos_aesthetics.items(): + if not is_scalar(val) and len(val) == 1: + pos_aesthetics[ae] = np.repeat(val, max_length) + # Stop pandas from complaining about all scalars if all(is_scalar(val) for val in pos_aesthetics.values()): for ae in pos_aesthetics: @@ -118,9 +137,14 @@ def __init__( f"...). Got {repr(geom)}" ) - mappings = aes(**{str(ae): ae for ae in data.columns}) + mappings = aes( + **{ + str(ae): f"I({ae})" if ae in asis_aes else str(ae) + for ae in data.columns + } + ) - # The positions are mapped, the rest are manual settings + # Map positions and pass the remaining arguments as manual settings. self._annotation_geom = geom_klass( mappings, data, diff --git a/plotnine/geoms/geom.py b/plotnine/geoms/geom.py index cf66e74144..fd02556e3f 100644 --- a/plotnine/geoms/geom.py +++ b/plotnine/geoms/geom.py @@ -217,6 +217,7 @@ def use_defaults( : Data used for drawing the geom. """ + from plotnine.mapping._asis import is_asis from plotnine.mapping._atomic import ae_value, broadcast_ae_value missing_aes = ( @@ -234,6 +235,7 @@ def use_defaults( for ae in evaled.columns.intersection(data.columns): data[ae] = evaled[ae] + n = len(data) num_panels = len(data["PANEL"].unique()) if "PANEL" in data else 1 across_panels = num_panels > 1 and not self.params["inherit_aes"] @@ -243,6 +245,10 @@ def use_defaults( data[ae] = value elif isinstance(value, ae_value): data[ae] = value * len(data) + elif is_asis(value): + # Preserve the dtype tag when repeating a literal parameter + # so position fractions remain available for later resolution. + data[ae] = np.repeat(value, n) if len(value) == 1 else value elif across_panels: value = list(chain(*repeat(value, num_panels))) data[ae] = value diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 843bcdefe6..cb31bf958d 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -507,6 +507,9 @@ def _build(self): # fill in the defaults layers.use_defaults_after_scale(scales) + # Resolve panel fractions after the ranges for every panel are known. + layers.resolve_asis_positions(layout, self.coordinates) + # Allow stats to modify the layer data layers.finish_statistics() diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index d64764ba77..93e047392b 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -8,6 +8,7 @@ from .._utils import MARGIN_SIDE, ensure_xy_location from .._utils.registry import Register +from ..mapping._asis import asis_columns from ..themes.theme import theme as Theme if TYPE_CHECKING: @@ -109,7 +110,7 @@ def legend_aesthetics(self, layer: layer): ) geom_ae = l.geom.REQUIRED_AES | l.geom.DEFAULT_AES.keys() matched = all_ae & geom_ae & legend_ae - matched = list(matched - set(l.geom.aes_params)) + matched = list(matched - set(l.geom.aes_params) - asis_columns(l.data)) return matched def _bind_source(self, plot: ggplot): diff --git a/plotnine/layer.py b/plotnine/layer.py index 7ef8631b26..1f5bf4637f 100644 --- a/plotnine/layer.py +++ b/plotnine/layer.py @@ -10,11 +10,19 @@ from ._utils import array_kind, check_required_aesthetics, ninteraction from ._utils.registry import Registry from .exceptions import PlotnineError -from .mapping.aes import NO_GROUP, aes, make_labels +from .mapping._asis import asis_columns +from .mapping.aes import ( + NO_GROUP, + POSITION_AESTHETICS, + X_AESTHETICS, + Y_AESTHETICS, + aes, + make_labels, +) from .mapping.evaluation import evaluate, stage if typing.TYPE_CHECKING: - from typing import Any, Sequence, SupportsIndex + from typing import Any, Literal, Sequence, SupportsIndex from plotnine import ggplot from plotnine.coords.coord import coord @@ -408,6 +416,43 @@ def compute_position(self, layout: Layout): data = self.position.compute_layer(data, params, layout) self.data = data + def resolve_asis_positions(self, layout: Layout, coord: coord): + """ + Convert panel fractions in position aesthetics to data coordinates + + Parameters + ---------- + layout : + Layout containing each panel's trained ranges. + coord : + Coordinate system that converts panel ranges to the + unflipped, untransformed data coordinates in the columns. + """ + data = self.data + if not len(data): + return + + columns = asis_columns(data) & POSITION_AESTHETICS + if not columns: + return + + # Convert integer fractions to float before replacing them with + # resolved coordinates; an integer column cannot hold those floats. + for col in columns: + data[col] = data[col].astype(float) + + by_dimension: tuple[tuple[set[str], Literal["x", "y"]], ...] = ( + (columns & X_AESTHETICS, "x"), + (columns & Y_AESTHETICS, "y"), + ) + for pid, idx in data.groupby("PANEL", observed=True).groups.items(): + panel_params = layout.panel_params[cast("int", pid) - 1] + for cols, dimension in by_dimension: + for col in cols: + data.loc[idx, col] = coord.panel_fraction_to_data( + data.loc[idx, col], panel_params, dimension + ) + def draw(self, layout: Layout, coord: coord): """ Draw geom @@ -550,6 +595,20 @@ def compute_position(self, layout: Layout): for l in self: l.compute_position(layout) + def resolve_asis_positions(self, layout: Layout, coord: coord): + """ + Convert panel fractions in position aesthetics to data coordinates + + Parameters + ---------- + layout : + Layout containing each panel's trained ranges. + coord : + Coordinate system used by each layer. + """ + for l in self: + l.resolve_asis_positions(layout, coord) + def use_defaults_after_scale(self, scales: Scales): for l in self: l.data = l.use_defaults(l.data, l.mapping._scaled, scales) diff --git a/plotnine/mapping/__init__.py b/plotnine/mapping/__init__.py index 50e8f98ad0..b75a693029 100644 --- a/plotnine/mapping/__init__.py +++ b/plotnine/mapping/__init__.py @@ -2,8 +2,9 @@ Aesthetic Mappings """ +from ._asis import I from ._env import Environment # noqa: F401 from .aes import aes from .evaluation import after_scale, after_stat, stage -__all__ = ("aes", "after_stat", "after_scale", "stage") +__all__ = ("I", "aes", "after_stat", "after_scale", "stage") diff --git a/plotnine/mapping/_asis.py b/plotnine/mapping/_asis.py new file mode 100644 index 0000000000..d73dfd89cd --- /dev/null +++ b/plotnine/mapping/_asis.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import ast +import re +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd +from pandas.api.extensions import ( + ExtensionArray, + ExtensionDtype, + ExtensionScalarOpsMixin, # pyright: ignore[reportAttributeAccessIssue] + register_extension_dtype, + take, +) + +if TYPE_CHECKING: + from typing import Any, Iterable, Sequence + + from numpy import _DTypeKind as DtypeKind + from pandas._typing import TakeIndexer + + +__all__ = ("I",) + +NAME_RE = re.compile(r"^asis\[(?P.+)\]$") + + +@register_extension_dtype +class AsIsDtype(ExtensionDtype): + """ + Dtype for a column whose values bypass scales + + The dtype stores the tag and the NumPy dtype of the values. Dataframe + operations that preserve dtypes therefore preserve the tag, while + `kind` and `type` keep the column's usual scale classification. + """ + + def __init__(self, subtype: Any = None): + self._subtype = np.dtype(object if subtype is None else subtype) + + @property + def subtype(self) -> np.dtype: + """ + NumPy dtype of the underlying values + """ + return self._subtype + + @property + def name(self) -> str: # pyright: ignore[reportIncompatibleVariableOverride] + """ + Name of the dtype, e.g. `asis[float64]` + """ + return f"asis[{self._subtype}]" + + @property + def kind(self) -> DtypeKind: + """ + Character code of the underlying NumPy dtype + """ + return self._subtype.kind + + @property + def type(self) -> type: # pyright: ignore[reportIncompatibleVariableOverride] + """ + Scalar type of the underlying NumPy dtype + """ + return self._subtype.type + + def __repr__(self) -> str: + return self.name + + def __eq__(self, other: object) -> bool: + if isinstance(other, str): + try: + other = type(self).construct_from_string(other) + except TypeError: + return False + return isinstance(other, AsIsDtype) and self._subtype == other._subtype + + def __hash__(self) -> int: + return hash(("asis", self._subtype)) + + @classmethod + def construct_from_string(cls, string: str) -> AsIsDtype: + """ + Construct a dtype from its name, such as `asis[float64]` + """ + if not (m := NAME_RE.match(string)): + raise TypeError(f"Cannot construct an AsIsDtype from {string!r}") + return cls(np.dtype(m.group("subtype"))) + + def construct_array_type(self) -> type[AsIsArray]: + """ + Return the extension array type for this dtype + """ + return AsIsArray + + +class AsIsArray(ExtensionArray, ExtensionScalarOpsMixin): + """ + Extension array of literal values backed by a NumPy array + + The values remain ordinary; the dtype carries the literal tag that + scales inspect. Comparing tagged arrays, such as `I(x) == I(y)`, + returns an untagged boolean array, so the derived column trains a + scale like any other expression. + """ + + _values: np.ndarray + + def __init__(self, values: Any): + arr = np.asarray(values) + # Fixed-width Unicode cannot hold the missing value expected by + # a pandas object column. + if arr.dtype.kind in "US": + arr = arr.astype(object) + self._values = arr + + @classmethod + def _from_sequence( + cls, + scalars: Iterable[Any], + *, + dtype: Any = None, + copy: bool = False, + ) -> AsIsArray: + """ + Construct an array from a sequence of values + """ + if isinstance(scalars, cls): + scalars = scalars._values + arr = np.asarray(scalars) + if isinstance(dtype, AsIsDtype): + arr = arr.astype(dtype.subtype) + elif copy: + arr = arr.copy() + return cls(arr) + + @classmethod + def _from_factorized( + cls, values: np.ndarray, original: AsIsArray + ) -> AsIsArray: + """ + Construct an array from `factorize` output + """ + return cls(values) + + @classmethod + def _concat_same_type(cls, to_concat: Sequence[AsIsArray]) -> AsIsArray: + """ + Concatenate arrays of this type + + The subtypes may differ, so the values are promoted to a + common numpy dtype first. Values with nothing in common + become objects. + """ + subtypes = [a._values.dtype for a in to_concat] + try: + common = np.result_type(*subtypes) + except TypeError: + common = np.dtype(object) + return cls( + np.concatenate([a._values.astype(common) for a in to_concat]) + ) + + @property + def dtype(self) -> AsIsDtype: + """ + Dtype marking the values as literal + """ + return AsIsDtype(self._values.dtype) + + @property + def nbytes(self) -> int: + """ + Number of bytes occupied by the values + """ + return self._values.nbytes + + def __len__(self) -> int: + return len(self._values) + + def __getitem__(self, item: Any) -> Any: + result = self._values[item] + if np.isscalar(result) or result is None or result is np.nan: + return result + if isinstance(item, (int, np.integer)): + return result + return type(self)(result) + + def __setitem__(self, key: Any, value: Any) -> None: + if isinstance(value, AsIsArray): + value = value._values + self._values[key] = value + + def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray: + return np.asarray(self._values, dtype=dtype) + + def isna(self) -> np.ndarray: + """ + Return a mask identifying missing values + """ + return pd.isna(self._values) + + def take( # pyright: ignore[reportIncompatibleMethodOverride] + self, + indices: TakeIndexer, + *, + allow_fill: bool = False, + fill_value: Any = None, + ) -> AsIsArray: + """ + Return values at the requested positions + """ + if allow_fill and fill_value is None: + fill_value = self.dtype.na_value + return type(self)( + take( + self._values, + indices, + fill_value=fill_value, + allow_fill=allow_fill, + ) + ) + + def copy(self) -> AsIsArray: + """ + Return an independent copy of the array + """ + return type(self)(self._values.copy()) + + def astype(self, dtype: Any, copy: bool = True) -> Any: + """ + Cast the values to another dtype + + Casting to anything but this dtype drops the tag, because + the result is no longer a column of literal values. + """ + dtype = pd.api.types.pandas_dtype(dtype) + if isinstance(dtype, AsIsDtype): + return self.copy() if copy else self + if isinstance(dtype, ExtensionDtype): + cls = dtype.construct_array_type() + return cls._from_sequence(self._values, dtype=dtype, copy=copy) # pyright: ignore[reportAttributeAccessIssue] + return self._values.astype(dtype, copy=copy) + + def _values_for_factorize(self) -> tuple[np.ndarray, Any]: + return self._values.astype(object), np.nan + + +# Comparisons, such as `I(x) == I(y)`, return plain booleans, so they +# train a scale like any other expression. Leave arithmetic undefined: +# `I(a) + I(b)` raises, matching the untagged `AsIs` it replaces. +AsIsArray._add_comparison_ops() + +# Expose the shorter name used in public signatures. `AsIsArray` follows +# pandas' extension-array convention, while `AsIs` describes its purpose. +AsIs = AsIsArray + + +def I(x: Any) -> Any: # noqa: E743 + """ + Mark an aesthetic value as literal + + A literal value bypasses scales, reaches the geom unchanged, and + contributes no training data, mapping, or legend. Use it to mix + literal and scaled values for one aesthetic across layers. + + For position aesthetics (`x`, `y`, and their `min`/`max`/`end`/ + `intercept` variants), the value represents a fraction of the + expanded panel range. `I(0)` and `I(1)` mark the panel edges, + `I(0.5)` marks its centre, and values outside `0` to `1` fall + outside the panel and are clipped like other out-of-range data. + Use this form to place annotations relative to the panel. + + Parameters + ---------- + x : + Value, or array of values, to use literally. + + Returns + ------- + : + The values in a column that bypasses scales. + + Notes + ----- + Use `I()` with `stat_identity`, the default stat for most geoms. + Behaviour through another stat or through `position_stack` / + `position_dodge` is undefined. + """ + if is_asis(x): + return x + return AsIsArray._from_sequence(np.atleast_1d(x)) + + +def is_asis(value: Any) -> bool: + """ + Return whether a value carries the literal tag + """ + return isinstance(getattr(value, "dtype", None), AsIsDtype) + + +def asis_columns(data: pd.DataFrame) -> set[str]: + """ + Return the columns in `data` that carry the literal tag + """ + return {str(col) for col in data.columns if is_asis(data[col])} + + +def is_literal_expression(value: Any) -> bool: + """ + Return whether a value is an unevaluated `I(...)` expression + + `annotate` and `aes()` can record `I()` as source text, such as + `"I(x)"`, before evaluating the layer data. This detects the + expression before evaluation produces an `AsIsDtype` value. + + The expression must be a single call to `I`. A compound expression + such as `"I(a) == I(b)"` keeps its ordinary label. Invalid Python, + including a column name with spaces or punctuation, is not a + literal expression. + """ + if not isinstance(value, str): + return False + try: + node = ast.parse(value.strip(), mode="eval").body + except (SyntaxError, ValueError): + return False + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "I" + ) diff --git a/plotnine/mapping/aes.py b/plotnine/mapping/aes.py index 39f3ab1878..fe58ae5b07 100644 --- a/plotnine/mapping/aes.py +++ b/plotnine/mapping/aes.py @@ -13,6 +13,7 @@ from mizani._colors.utils import is_color_tuple from ..iapi import labels_view +from ._asis import is_asis, is_literal_expression from .evaluation import after_stat, stage if TYPE_CHECKING: @@ -181,11 +182,12 @@ class aes(Dict[str, Any]): ggplot(df, aes(x="df.index", y="np.sin(gam ma)")) ``` - `aes` has 2 internal functions that you can use in your expressions + `aes` has 3 internal functions that you can use in your expressions when transforming the variables. 1. [](:func:`~plotnine.mapping._eval_environment.factor`) 1. [](:func:`~plotnine.mapping._eval_environment.reorder`) + 1. [](:func:`~plotnine.I`) **The group aesthetic** @@ -518,7 +520,9 @@ def make_labels(mapping: dict[str, Any] | aes) -> labels_view: """ def _nice_label(value: Any) -> str | None: - if isinstance(value, str): + if is_asis(value) or is_literal_expression(value): + return None + elif isinstance(value, str): return value elif isinstance(value, pd.Series): return value.name # pyright: ignore diff --git a/plotnine/mapping/evaluation.py b/plotnine/mapping/evaluation.py index 72ea31f171..1077bed8f5 100644 --- a/plotnine/mapping/evaluation.py +++ b/plotnine/mapping/evaluation.py @@ -8,6 +8,7 @@ import pandas.api.types as pdtypes from ..exceptions import PlotnineError +from ._asis import I from ._eval_environment import factor, reorder if TYPE_CHECKING: @@ -20,7 +21,7 @@ __all__ = ("after_stat", "after_scale", "stage") -EVAL_ENVIRONMENT = {"factor": factor, "reorder": reorder} +EVAL_ENVIRONMENT = {"factor": factor, "reorder": reorder, "I": I} _TPL_EVAL_FAIL = """\ Could not evaluate the '{}' mapping: '{}' \ diff --git a/plotnine/scales/scale.py b/plotnine/scales/scale.py index 1a1eb2151d..45dffc768e 100644 --- a/plotnine/scales/scale.py +++ b/plotnine/scales/scale.py @@ -9,6 +9,7 @@ from .._utils.registry import Register from ..exceptions import PlotnineError +from ..mapping._asis import is_asis from ..mapping.aes import is_position_aes, rename_aesthetics from ._runtime_typing import ( BreaksUserT, @@ -279,7 +280,11 @@ def train_df(self, df: pd.DataFrame): """ Train scale from a dataframe """ - aesthetics = sorted(set(self.aesthetics) & set(df.columns)) + aesthetics = sorted( + ae + for ae in set(self.aesthetics) & set(df.columns) + if not is_asis(df[ae]) + ) for ae in aesthetics: self.train(df[ae]) @@ -290,7 +295,11 @@ def map_df(self, df: pd.DataFrame) -> pd.DataFrame: if len(df) == 0: return df - aesthetics = set(self.aesthetics) & set(df.columns) + aesthetics = { + ae + for ae in set(self.aesthetics) & set(df.columns) + if not is_asis(df[ae]) + } for ae in aesthetics: df[ae] = self.map(df[ae]) diff --git a/plotnine/scales/scale_continuous.py b/plotnine/scales/scale_continuous.py index 8359e9dc25..3ae8f98fb7 100644 --- a/plotnine/scales/scale_continuous.py +++ b/plotnine/scales/scale_continuous.py @@ -13,6 +13,7 @@ from .._utils import match from ..exceptions import PlotnineError, PlotnineWarning from ..iapi import range_view, scale_view +from ..mapping._asis import is_asis from ._expand import expand_range from ._runtime_typing import ( ContinuousBreaksUser, @@ -216,7 +217,11 @@ def transform_df(self, df: pd.DataFrame) -> pd.DataFrame: if len(df) == 0: return df - aesthetics = set(self.aesthetics) & set(df.columns) + aesthetics = { + ae + for ae in set(self.aesthetics) & set(df.columns) + if not is_asis(df[ae]) + } for ae in aesthetics: with suppress(TypeError): df[ae] = self.transform(df[ae]) @@ -236,7 +241,11 @@ def inverse_df(self, df): if len(df) == 0: return df - aesthetics = set(self.aesthetics) & set(df.columns) + aesthetics = { + ae + for ae in set(self.aesthetics) & set(df.columns) + if not is_asis(df[ae]) + } for ae in aesthetics: with suppress(TypeError): df[ae] = self.inverse(df[ae]) diff --git a/plotnine/scales/scales.py b/plotnine/scales/scales.py index fb6c9b1ba7..aa791533f3 100644 --- a/plotnine/scales/scales.py +++ b/plotnine/scales/scales.py @@ -12,6 +12,7 @@ from .._utils import array_kind from .._utils.registry import Registry from ..exceptions import PlotnineError, PlotnineWarning +from ..mapping._asis import is_asis from ..mapping.aes import aes_to_scale from .scale import scale @@ -286,6 +287,8 @@ def add_defaults(self, data, aesthetics): col = aesthetics[ae] if col not in data: col = ae + if col in data and is_asis(data[col]): + continue scale_var = aes_to_scale(ae) if self.get_scales(scale_var): diff --git a/tests/baseline_images/test_asis/annotate.png b/tests/baseline_images/test_asis/annotate.png new file mode 100644 index 0000000000..b71d5dd6cc Binary files /dev/null and b/tests/baseline_images/test_asis/annotate.png differ diff --git a/tests/baseline_images/test_asis/annotation_coord_flip.png b/tests/baseline_images/test_asis/annotation_coord_flip.png new file mode 100644 index 0000000000..a8bc6495bb Binary files /dev/null and b/tests/baseline_images/test_asis/annotation_coord_flip.png differ diff --git a/tests/baseline_images/test_asis/asis_in_mapping.png b/tests/baseline_images/test_asis/asis_in_mapping.png new file mode 100644 index 0000000000..919dd1bd95 Binary files /dev/null and b/tests/baseline_images/test_asis/asis_in_mapping.png differ diff --git a/tests/baseline_images/test_asis/literal_colours.png b/tests/baseline_images/test_asis/literal_colours.png new file mode 100644 index 0000000000..c9df6508b1 Binary files /dev/null and b/tests/baseline_images/test_asis/literal_colours.png differ diff --git a/tests/test_asis.py b/tests/test_asis.py new file mode 100644 index 0000000000..2dad47ca74 --- /dev/null +++ b/tests/test_asis.py @@ -0,0 +1,64 @@ +import pandas as pd + +from plotnine import ( + I, + aes, + annotate, + coord_flip, + geom_label, + geom_point, + ggplot, +) +from plotnine.data import mtcars + +colours = ["red", "green", "blue", "red"] +data = pd.DataFrame( + {"x": [1, 2, 3, 4], "y": [1, 4, 9, 16], "c": ["a", "b", "a", "b"]} +) + + +def test_literal_colours(): + my_colours = pd.cut( + mtcars["wt"], 3, labels=["red", "blue", "green"] + ).to_list() + p = ggplot(mtcars, aes("wt", "mpg")) + geom_point( + aes(colour=I(my_colours)), size=3 + ) + assert p == "literal_colours" + + +def test_annotate(): + p = ( + ggplot(mtcars, aes("wt", "mpg")) + + geom_point(colour="grey") + + annotate( + "text", + label="Text in the middle", + x=I(0.5), + y=I(0.5), + size=12, + ) + ) + assert p == "annotate" + + +def test_annotate_coord_flip(): + p = ( + ggplot(mtcars, aes("wt", "mpg")) + + geom_point(colour="grey") + + annotate("label", label="90/10", x=I(0.9), y=I(0.1), size=12) + + coord_flip() + ) + assert p == "annotation_coord_flip" + + +def test_asis_in_mapping(): + p = ( + ggplot(mtcars) + + geom_point(aes("wt", "mpg", colour="factor(cyl)")) + + geom_label( + aes(x="I(x)", y="I(y)", label="label"), + pd.DataFrame({"x": [0.5], "y": [0.9], "label": ["a label"]}), + ) + ) + assert p == "asis_in_mapping" diff --git a/tests/test_guide_internals.py b/tests/test_guide_internals.py index 9205834a54..a1e6f12ffa 100644 --- a/tests/test_guide_internals.py +++ b/tests/test_guide_internals.py @@ -3,9 +3,11 @@ import pandas as pd from plotnine import ( + I, aes, after_scale, geom_bar, + geom_line, geom_point, ggplot, stage, @@ -60,3 +62,24 @@ def test_guide_legend_missing_value_for_shapes(): data = pd.DataFrame({"a": [1, 2, 3], "b": ["a", None, "z"]}) p = ggplot(data, aes("a", "b")) + geom_point(aes(shape="b"), na_rm=True) assert p == "guide_legend_missing_value_for_shapes" + + +def test_literal_layer_does_not_join_another_layer_guide(): + # A layer with a literal colour has no scale of its own. Its glyph + # must not join a guide trained from another layer's colour mapping. + colours = ["red", "green", "blue", "red"] + data = pd.DataFrame( + {"x": [1, 2, 3, 4], "y": [1, 4, 9, 16], "c": ["a", "b", "a", "b"]} + ) + p = ( + ggplot(data, aes("x", "y")) + + geom_point(aes(colour=I(colours))) + + geom_line(aes(colour="c")) + ) + p.draw_test() + + ((_, g),) = p.guides._lookup.values() + contributing = [ + lp.layer.geom.__class__.__name__ for lp in g._layer_parameters + ] + assert contributing == ["geom_line"]