Skip to content

Commit

Permalink
Add Scale.label interface for formatting ticks (#2877)
Browse files Browse the repository at this point in the history
* Step one of bikeshedding scale names

* Remove concept of separate Scale / ScaleSpec

* Begin transitioning Scale tick/label parameterization to declarations

* Add initial draft of Continuous.label API

* Note to self

* Add label tests

* Add docstring for Continuous.label

* Fix tests

* Partialy update Nominal

* Hide _priority from dataclass signature

* A little more test coverage and documentation

* MPL<3.3 compat in temporal tests

* Use (sym)log locator / formatter for symlog scales by default

* Remove code duplication

* Rename transform parameter to trans

* Pass through residual TODO comments

* Lint
  • Loading branch information
mwaskom authored Jun 26, 2022
1 parent 2b9f85b commit c2270e7
Show file tree
Hide file tree
Showing 9 changed files with 676 additions and 374 deletions.
5 changes: 0 additions & 5 deletions pytest.ini

This file was deleted.

40 changes: 20 additions & 20 deletions seaborn/_core/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from seaborn._stats.base import Stat
from seaborn._core.data import PlotData
from seaborn._core.moves import Move
from seaborn._core.scales import ScaleSpec, Scale
from seaborn._core.scales import Scale
from seaborn._core.subplots import Subplots
from seaborn._core.groupby import GroupBy
from seaborn._core.properties import PROPERTIES, Property, Coordinate
Expand Down Expand Up @@ -146,7 +146,7 @@ class Plot:

_data: PlotData
_layers: list[Layer]
_scales: dict[str, ScaleSpec]
_scales: dict[str, Scale]

_subplot_spec: dict[str, Any] # TODO values type
_facet_spec: FacetSpec
Expand Down Expand Up @@ -520,7 +520,7 @@ def facet(

# TODO def twin()?

def scale(self, **scales: ScaleSpec) -> Plot:
def scale(self, **scales: Scale) -> Plot:
"""
Control mappings from data units to visual properties.
Expand Down Expand Up @@ -873,7 +873,7 @@ def _transform_coords(self, p: Plot, common: PlotData, layers: list[Layer]) -> N
var_df = pd.DataFrame(columns=cols)

prop = Coordinate(axis)
scale_spec = self._get_scale(p, prefix, prop, var_df[var])
scale = self._get_scale(p, prefix, prop, var_df[var])

# Shared categorical axes are broken on matplotlib<3.4.0.
# https://github.com/matplotlib/matplotlib/pull/18308
Expand All @@ -882,7 +882,7 @@ def _transform_coords(self, p: Plot, common: PlotData, layers: list[Layer]) -> N
if Version(mpl.__version__) < Version("3.4.0"):
from seaborn._core.scales import Nominal
paired_axis = axis in p._pair_spec
cat_scale = isinstance(scale_spec, Nominal)
cat_scale = isinstance(scale, Nominal)
ok_dim = {"x": "col", "y": "row"}[axis]
shared_axes = share_state not in [False, "none", ok_dim]
if paired_axis and cat_scale and shared_axes:
Expand All @@ -897,7 +897,7 @@ def _transform_coords(self, p: Plot, common: PlotData, layers: list[Layer]) -> N
# Setup the scale on all of the data and plug it into self._scales
# We do this because by the time we do self._setup_scales, coordinate data
# will have been converted to floats already, so scale inference fails
self._scales[var] = scale_spec.setup(var_df[var], prop)
self._scales[var] = scale._setup(var_df[var], prop)

# Set up an empty series to receive the transformed values.
# We need this to handle piecemeal tranforms of categories -> floats.
Expand Down Expand Up @@ -927,7 +927,7 @@ def _transform_coords(self, p: Plot, common: PlotData, layers: list[Layer]) -> N

seed_values = var_df.loc[idx, var]

scale = scale_spec.setup(seed_values, prop, axis=axis_obj)
scale = scale._setup(seed_values, prop, axis=axis_obj)

for layer, new_series in zip(layers, transformed_data):
layer_df = layer["data"].frame
Expand All @@ -936,7 +936,7 @@ def _transform_coords(self, p: Plot, common: PlotData, layers: list[Layer]) -> N
new_series.loc[idx] = scale(layer_df.loc[idx, var])

# TODO need decision about whether to do this or modify axis transform
set_scale_obj(view["ax"], axis, scale.matplotlib_scale)
set_scale_obj(view["ax"], axis, scale._matplotlib_scale)

# Now the transformed data series are complete, set update the layer data
for layer, new_series in zip(layers, transformed_data):
Expand Down Expand Up @@ -1000,11 +1000,11 @@ def _compute_stats(self, spec: Plot, layers: list[Layer]) -> None:

def _get_scale(
self, spec: Plot, var: str, prop: Property, values: Series
) -> ScaleSpec:
) -> Scale:

if var in spec._scales:
arg = spec._scales[var]
if arg is None or isinstance(arg, ScaleSpec):
if arg is None or isinstance(arg, Scale):
scale = arg
else:
scale = prop.infer_scale(arg, values)
Expand Down Expand Up @@ -1052,28 +1052,28 @@ def _setup_scales(self, p: Plot, layers: list[Layer]) -> None:
axis = m["axis"]

prop = PROPERTIES.get(var if axis is None else axis, Property())
scale_spec = self._get_scale(p, var, prop, var_values)
scale = self._get_scale(p, var, prop, var_values)

# Initialize the data-dependent parameters of the scale
# Note that this returns a copy and does not mutate the original
# This dictionary is used by the semantic mappings
if scale_spec is None:
if scale is None:
# TODO what is the cleanest way to implement identity scale?
# We don't really need a ScaleSpec, and Identity() will be
# We don't really need a Scale, and Identity() will be
# overloaded anyway (but maybe a general Identity object
# that can be used as Scale/Mark/Stat/Move?)
# Note that this may not be the right spacer to use
# (but that is only relevant for coordinates, where identity scale
# doesn't make sense or is poorly defined, since we don't use pixels.)
self._scales[var] = Scale([], lambda x: x, None, "identity", None)
self._scales[var] = Scale._identity()
else:
scale = scale_spec.setup(var_values, prop)
scale = scale._setup(var_values, prop)
if isinstance(prop, Coordinate):
# If we have a coordinate here, we didn't assign a scale for it
# in _transform_coords, which means it was added during compute_stat
# This allows downstream orientation inference to work properly.
# But it feels a little hacky, so perhaps revisit.
scale.scale_type = "computed"
scale._priority = 0 # type: ignore
self._scales[var] = scale

def _plot_layer(self, p: Plot, layer: Layer) -> None:
Expand All @@ -1097,14 +1097,14 @@ def get_order(var):
# sorted unique numbers will correctly reconstruct intended order
# TODO This is tricky, make sure we add some tests for this
if var not in "xy" and var in scales:
return scales[var].order
return getattr(scales[var], "order", None)

if "width" in mark._mappable_props:
width = mark._resolve(df, "width", None)
else:
width = df.get("width", 0.8) # TODO what default
if orient in df:
df["width"] = width * scales[orient].spacing(df[orient])
df["width"] = width * scales[orient]._spacing(df[orient])

if "baseline" in mark._mappable_props:
# TODO what marks should have this?
Expand Down Expand Up @@ -1277,7 +1277,7 @@ def _setup_split_generator(
v for v in grouping_vars if v in df and v not in ["col", "row"]
]
for var in grouping_vars:
order = self._scales[var].order
order = getattr(self._scales[var], "order", None)
if order is None:
order = categorical_order(df[var])
grouping_keys.append(order)
Expand Down Expand Up @@ -1357,7 +1357,7 @@ def _update_legend_contents(
]] = []
schema = []
for var in legend_vars:
var_legend = scales[var].legend
var_legend = scales[var]._legend
if var_legend is not None:
values, labels = var_legend
for (_, part_id), part_vars, _ in schema:
Expand Down
24 changes: 12 additions & 12 deletions seaborn/_core/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from matplotlib.colors import to_rgb, to_rgba, to_rgba_array
from matplotlib.path import Path

from seaborn._core.scales import ScaleSpec, Nominal, Continuous, Temporal
from seaborn._core.scales import Scale, Nominal, Continuous, Temporal
from seaborn._core.rules import categorical_order, variable_type
from seaborn._compat import MarkerStyle
from seaborn.palettes import QUAL_PALETTES, color_palette, blend_palette
Expand Down Expand Up @@ -59,7 +59,7 @@ def __init__(self, variable: str | None = None):
variable = self.__class__.__name__.lower()
self.variable = variable

def default_scale(self, data: Series) -> ScaleSpec:
def default_scale(self, data: Series) -> Scale:
"""Given data, initialize appropriate scale class."""
# TODO allow variable_type to be "boolean" if that's a scale?
# TODO how will this handle data with units that can be treated as numeric
Expand All @@ -75,7 +75,7 @@ def default_scale(self, data: Series) -> ScaleSpec:
else:
return Nominal()

def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
def infer_scale(self, arg: Any, data: Series) -> Scale:
"""Given data and a scaling argument, initialize appropriate scale class."""
# TODO put these somewhere external for validation
# TODO putting this here won't pick it up if subclasses define infer_scale
Expand All @@ -86,7 +86,7 @@ def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
if isinstance(arg, str):
if any(arg.startswith(k) for k in trans_args):
# TODO validate numeric type? That should happen centrally somewhere
return Continuous(transform=arg)
return Continuous(trans=arg)
else:
msg = f"Unknown magic arg for {self.variable} scale: '{arg}'."
raise ValueError(msg)
Expand All @@ -96,7 +96,7 @@ def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
raise TypeError(msg)

def get_mapping(
self, scale: ScaleSpec, data: Series
self, scale: Scale, data: Series
) -> Callable[[ArrayLike], ArrayLike]:
"""Return a function that maps from data domain to property range."""
def identity(x):
Expand Down Expand Up @@ -176,7 +176,7 @@ def _inverse(self, values: ArrayLike) -> ArrayLike:
"""Transform applied to results of mapping that returns to native values."""
return values

def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
def infer_scale(self, arg: Any, data: Series) -> Scale:
"""Given data and a scaling argument, initialize appropriate scale class."""

# TODO infer continuous based on log/sqrt etc?
Expand All @@ -192,7 +192,7 @@ def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
return Continuous(arg)

def get_mapping(
self, scale: ScaleSpec, data: ArrayLike
self, scale: Scale, data: ArrayLike
) -> Callable[[ArrayLike], ArrayLike]:
"""Return a function that maps from data domain to property range."""
if isinstance(scale, Nominal):
Expand Down Expand Up @@ -325,7 +325,7 @@ def infer_scale(self, arg: Any, data: Series) -> Nominal:
return Nominal(arg)

def get_mapping(
self, scale: ScaleSpec, data: Series,
self, scale: Scale, data: Series,
) -> Callable[[ArrayLike], list]:
"""Define mapping as lookup into list of object values."""
order = getattr(scale, "order", None)
Expand Down Expand Up @@ -532,7 +532,7 @@ def has_alpha(x):
else:
return to_rgba_array(colors)[:, :3]

def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
def infer_scale(self, arg: Any, data: Series) -> Scale:
# TODO when inferring Continuous without data, verify type

# TODO need to rethink the variable type system
Expand Down Expand Up @@ -617,7 +617,7 @@ def mapping(x):
return mapping

def get_mapping(
self, scale: ScaleSpec, data: Series
self, scale: Scale, data: Series
) -> Callable[[ArrayLike], ArrayLike]:
"""Return a function that maps from data domain to color values."""
# TODO what is best way to do this conditional?
Expand Down Expand Up @@ -690,13 +690,13 @@ def default_scale(self, data: Series) -> Nominal:
"""Given data, initialize appropriate scale class."""
return Nominal()

def infer_scale(self, arg: Any, data: Series) -> ScaleSpec:
def infer_scale(self, arg: Any, data: Series) -> Scale:
"""Given data and a scaling argument, initialize appropriate scale class."""
# TODO infer Boolean where possible?
return Nominal(arg)

def get_mapping(
self, scale: ScaleSpec, data: Series
self, scale: Scale, data: Series
) -> Callable[[ArrayLike], ArrayLike]:
"""Return a function that maps each data value to True or False."""
# TODO categorical_order is going to return [False, True] for booleans,
Expand Down
Loading

0 comments on commit c2270e7

Please sign in to comment.