diff --git a/seaborn/_core/mappings.py b/seaborn/_core/mappings.py index 6637d0f063..f725815d30 100644 --- a/seaborn/_core/mappings.py +++ b/seaborn/_core/mappings.py @@ -218,7 +218,13 @@ def setup( return LookupMapping(mapping_dict) if not isinstance(values, tuple): - raise TypeError() # TODO + # What to do here? In existing code we can pass numeric data but + # then request a categorical mapping by using a list or dict for values. + # That is currently not supported because the scale.type dominates in + # the variable type inference. We should basically not get here, either + # passing a list/dict implies a categorical mapping, or the an explicit + # numeric mapping with a categorical set of values should raise before this. + raise TypeError() # TODO FIXME if map_type == "numeric": @@ -228,11 +234,13 @@ def setup( elif map_type == "datetime": if scale is not None: + # TODO should this happen upstream, or alternatively inside the norm? data = scale.cast(data) data = mpl.dates.date2num(data.dropna()) prepare = lambda x: mpl.dates.date2num(pd.to_datetime(x)) # TODO if norm is tuple, convert to datetime and then to numbers? + # (Or handle that upstream within the DateTimeScale? Probably do this.) transform = RangeTransform(values) @@ -266,9 +274,18 @@ def setup( norm = None if scale is None else scale.norm order = None if scale is None else scale.order - # TODO We need to add some input checks ... + # TODO We also need to add some input checks ... # e.g. specifying a numeric scale and a qualitative colormap should fail nicely. + # TODO FIXME:mappings + # In current function interface, we can assign a numeric variable to hue and set + # either a named qualitative palette or a list/dict of colors. + # In current implementation here, that raises with an unpleasant error. + # The problem is that the scale.type currently dominates. + # How to distinguish between "user set numeric scale and qualitative palette, + # this is an error" from "user passed numeric values but did not set explicit + # scale, then asked for a qualitative mapping by the form of the palette? + map_type = self._infer_map_type(scale, palette, data) if map_type == "categorical": diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index ce5a39c818..8017a5657e 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -561,6 +561,9 @@ def _setup_data(self): def _setup_scales(self) -> None: + # TODO currently typoing variable name in `scale_*`, or scaling a variable that + # isn't defined anywhere, silently does nothing. We should raise/warn on that. + variables = set(self._data.frame) for layer in self._layers: variables |= set(layer.data.frame) diff --git a/seaborn/tests/_core/test_mappings.py b/seaborn/tests/_core/test_mappings.py index 98cdcd573b..dfe90870e2 100644 --- a/seaborn/tests/_core/test_mappings.py +++ b/seaborn/tests/_core/test_mappings.py @@ -47,6 +47,14 @@ def cat_vector(self, long_df): def cat_order(self, cat_vector): return categorical_order(cat_vector) + @pytest.fixture + def dt_num_vector(self, long_df): + return long_df["t"] + + @pytest.fixture + def dt_cat_vector(self, long_df): + return long_df["d"] + def test_categorical_default_palette(self, cat_vector, cat_order): expected = dict(zip(cat_order, color_palette())) @@ -302,6 +310,60 @@ def test_numeric_multi_lookup(self, num_vector, num_norm): expected_colors = cmap(num_norm(num_vector.to_numpy()))[:, :3] assert_array_equal(m(num_vector.to_numpy()), expected_colors) + def test_datetime_default_palette(self, dt_num_vector): + + m = ColorSemantic().setup(dt_num_vector) + mapped = m(dt_num_vector) + + tmp = dt_num_vector - dt_num_vector.min() + normed = tmp / tmp.max() + + expected_cmap = color_palette("ch:", as_cmap=True) + expected = expected_cmap(normed) + + assert len(mapped) == len(expected) + for have, want in zip(mapped, expected): + assert to_rgb(have) == to_rgb(want) + + def test_datetime_specified_palette(self, dt_num_vector): + + palette = "mako" + m = ColorSemantic(palette=palette).setup(dt_num_vector) + mapped = m(dt_num_vector) + + tmp = dt_num_vector - dt_num_vector.min() + normed = tmp / tmp.max() + + expected_cmap = color_palette(palette, as_cmap=True) + expected = expected_cmap(normed) + + assert len(mapped) == len(expected) + for have, want in zip(mapped, expected): + assert to_rgb(have) == to_rgb(want) + + @pytest.mark.xfail(reason="No support for norms in datetime scale yet") + def test_datetime_norm_limits(self, dt_num_vector): + + norm = ( + dt_num_vector.min() - pd.Timedelta(2, "m"), + dt_num_vector.max() - pd.Timedelta(1, "m"), + ) + palette = "mako" + + scale = ScaleWrapper(LinearScale("color"), "datetime", norm) + m = ColorSemantic(palette=palette).setup(dt_num_vector, scale) + mapped = m(dt_num_vector) + + tmp = dt_num_vector - norm[0] + normed = tmp / norm[1] + + expected_cmap = color_palette(palette, as_cmap=True) + expected = expected_cmap(normed) + + assert len(mapped) == len(expected) + for have, want in zip(mapped, expected): + assert to_rgb(have) == to_rgb(want) + def test_bad_palette(self, num_vector): with pytest.raises(ValueError): @@ -583,6 +645,25 @@ def test_norm_categorical(self): with pytest.raises(ValueError): self.semantic().setup(x, scale) + def test_default_datetime(self): + + x = pd.Series(np.array([10000, 10100, 10101], dtype="datetime64[D]")) + y = self.semantic().setup(x)(x) + tmp = x - x.min() + normed = tmp / tmp.max() + expected = self.transform(normed, *self.semantic().default_range) + assert_array_equal(y, expected) + + def test_range_datetime(self): + + values = .2, .9 + x = pd.Series(np.array([10000, 10100, 10101], dtype="datetime64[D]")) + y = self.semantic(values).setup(x)(x) + tmp = x - x.min() + normed = tmp / tmp.max() + expected = self.transform(normed, *values) + assert_array_equal(y, expected) + class TestWidth(ContinuousBase):