Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/_quartodoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ quartodoc:
- after_stat
- after_scale
- stage
- I

- subtitle: Functions in the Aesthetic Evaluation Environment
desc: |
Expand Down
10 changes: 10 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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()`,
Expand Down
2 changes: 2 additions & 0 deletions plotnine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
ylab,
)
from .mapping import (
I,
aes,
after_scale,
after_stat,
Expand Down Expand Up @@ -287,6 +288,7 @@
)

__all__ = (
"I",
"aes",
"after_scale",
"after_stat",
Expand Down
24 changes: 23 additions & 1 deletion plotnine/coords/coord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion plotnine/coords/coord_trans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +21,7 @@
from plotnine.scales.scale_xy import ScaleX, ScaleY
from plotnine.typing import (
FloatArray,
FloatArrayLike,
FloatSeries,
TFloatArrayLike,
)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions plotnine/facets/facet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -274,18 +275,21 @@ 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()
panel_scales_x.train(data, x_vars, SCALE_X)

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()
Expand Down
14 changes: 12 additions & 2 deletions plotnine/facets/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 37 additions & 13 deletions plotnine/geoms/annotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

import typing

import numpy as np
import pandas as pd

from .._utils import is_scalar
from .._utils.registry import Registry
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:
Expand Down Expand Up @@ -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}.
"""

Expand All @@ -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()
Expand All @@ -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)))

Expand All @@ -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:
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions plotnine/geoms/geom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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"]

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions plotnine/ggplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 2 additions & 1 deletion plotnine/guides/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading