diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 6ac24bdce..9112478f8 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -135,7 +135,7 @@ quartodoc: yield varied results, so a `stat` must be paired with a `geom` that can represent all or some of the computations. - - package: plotnine.stats.stat + - package: plotnine.stats.stat contents: - stat @@ -255,11 +255,18 @@ quartodoc: - scale_color_identity - scale_colour_identity - scale_fill_identity + - scale_hatch_identity - scale_linetype_identity - scale_shape_identity - scale_size_identity - scale_stroke_identity + - subtitle: Hatch Scales + options: *no-members + contents: + - scale_hatch + - scale_hatch_discrete + - subtitle: Linetype Scales options: *no-members contents: @@ -273,6 +280,7 @@ quartodoc: - scale_color_manual - scale_colour_manual - scale_fill_manual + - scale_hatch_manual - scale_linetype_manual - scale_shape_manual - scale_size_manual @@ -369,7 +377,6 @@ quartodoc: - position_nudge - position_stack - - title: Themes desc: | Themes control the visual appearance of the non-data elements the plot. @@ -652,11 +659,11 @@ quartodoc: Functions that you may occasioanally find helpful package: plotnine.helpers contents: - - get_aesthetic_limits + - get_aesthetic_limits - package: plotnine.session contents: - - last_plot + - last_plot - title: Datasets desc: | diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 03a71f81d..1e605c9d1 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -7,6 +7,16 @@ title: Changelog ### New +- [](:class:`~plotnine.geom_bar`) and [](:class:`~plotnine.geom_col`) gained a + `hatch` aesthetic. Bars can now be hatched either literally (e.g. + `geom_col(hatch="//")`) or as a discrete mapping + (e.g. `aes(hatch="grp")`). The hatch color is taken from the `color` + aesthetic, following matplotlib's edgecolor convention. A family of new + scales is available: [](:class:`~plotnine.scale_hatch`), + [](:class:`~plotnine.scale_hatch_discrete`), + [](:class:`~plotnine.scale_hatch_identity`), and + [](:class:`~plotnine.scale_hatch_manual`) for custom per-level patterns. + - You can now add a secondary axis to a plot, either as a one-to-one transformation of the primary axis with [](:class:`~plotnine.sec_axis`), or as a mirror of the primary axis with [](:func:`~plotnine.dup_axis`). diff --git a/plotnine/__init__.py b/plotnine/__init__.py index f1dbed840..510d70e20 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -185,6 +185,10 @@ scale_fill_identity, scale_fill_manual, scale_fill_ordinal, + scale_hatch, + scale_hatch_discrete, + scale_hatch_identity, + scale_hatch_manual, scale_linetype, scale_linetype_discrete, scale_linetype_identity, @@ -439,6 +443,10 @@ "scale_fill_identity", "scale_fill_manual", "scale_fill_ordinal", + "scale_hatch", + "scale_hatch_discrete", + "scale_hatch_identity", + "scale_hatch_manual", "scale_linetype", "scale_linetype_discrete", "scale_linetype_identity", diff --git a/plotnine/geoms/geom_polygon.py b/plotnine/geoms/geom_polygon.py index b9e3b45f9..fcc8be1e7 100644 --- a/plotnine/geoms/geom_polygon.py +++ b/plotnine/geoms/geom_polygon.py @@ -6,6 +6,7 @@ from .._utils import SIZE_FACTOR, to_rgba from ..doctools import document +from ..exceptions import PlotnineError from .geom import geom from .geom_path import geom_path @@ -23,6 +24,74 @@ from plotnine.layer import layer +# key side (pt) at which a one-character pattern reads as a pattern on its own +HATCH_KEY_SIZE = 32 +_VALID_HATCH = set(r"-+|/\xXoO.*") + + +def _add_hatch_overlays(ax, polygons, hatch, color, alpha, params, cls): + """ + Draw hatch strokes over polygons that another collection already filled + + A matplotlib `Collection` takes one hatch pattern for all of its paths, so + each distinct pattern needs a collection of its own. These carry no fill + and no border: the caller's collection drew both, and drawing the border + again would double its stroke and lose its linetype. + """ + import matplotlib as mpl + import pandas as pd + + # A fresh Series indexes by position, so the groups below can index into + # `polygons`; object dtype keeps categorical hatch column from rejecting "" + hatch = pd.Series(list(hatch), dtype=object).fillna("") + if not hatch.ne("").any(): + return + + # A non-string reaches set_hatch intact and fails in the renderer, and an + # unhashable one fails in the groupby below, so check before either. + bad = next((h for h in hatch if not isinstance(h, str)), None) + if bad is not None: + raise PlotnineError( + f"Cannot interpret hatch pattern {bad!r}. Hatch must be a string " + "(e.g. '/', '//', 'xx', '.o'). If you mapped a variable to " + "hatch, ensure it is a string column." + ) + + # matplotlib takes the hatch colour from the edge, and plotnine's default + # `color` is None -- which would leave the strokes invisible. Fall back to + # the colour the legend key ends up using. + colors = to_rgba( + pd.Series(list(color), dtype=object).fillna( + mpl.rcParams["patch.edgecolor"] + ), + list(alpha), + ) + + for pattern, idx in hatch.groupby(hatch).groups.items(): + if not pattern: + continue + # matplotlib will allow some characters to fail silently (eg.: by + # drawing an empty bar), so we raise here. + invalid = set(pattern) - _VALID_HATCH + if invalid: + raise PlotnineError( + f"Cannot interpret hatch pattern {pattern!r}: " + f"{''.join(sorted(invalid))!r} is not a hatch character. " + r"Valid characters are '-+|/\xXoO.*'." + ) + idx = list(idx) + overlay = cls( + [polygons[i] for i in idx], + facecolors="none", + edgecolors=[colors[i] for i in idx], + linewidths=0, + zorder=params["zorder"], + rasterized=params["raster"], + ) + overlay.set_hatch(pattern) + ax.add_collection(overlay) + + @document class geom_polygon(geom): """ @@ -112,6 +181,8 @@ def draw_group( edgecolor = [] linestyle = [] linewidth = [] + hatch = [] + alpha = [] # Some stats may order the data in ways that prevent # objects from occluding other objects. We do not want @@ -135,6 +206,8 @@ def draw_group( edgecolor.append(df["color"].iloc[0] or "none") linestyle.append(df["linetype"].iloc[0]) linewidth.append(df["linewidth"].iloc[0]) + hatch.append(df["hatch"].iloc[0] if "hatch" in df else None) + alpha.append(df["alpha"].iloc[0]) cls = PathCollection if has_subgroups else PolyCollection col = cls( @@ -148,6 +221,15 @@ def draw_group( ) ax.add_collection(col) + _add_hatch_overlays( + ax, + polygons, + hatch, + [e if e != "none" else None for e in edgecolor], + alpha, + params, + cls, + ) @staticmethod def draw_legend( @@ -183,6 +265,18 @@ def draw_legend( if facecolor is None: facecolor = "none" + # matplotlib tiles the hatch at a fixed physical size, so a key already + # shows more strokes the bigger it is. Repeat the pattern only while + # the key is too small to show it; a key of HATCH_KEY_SIZE or more + # shows the pattern at the same density as the panel. + hatch = data.get("hatch") + if isinstance(hatch, str) and hatch: + size = max(min(da.width, da.height), 1) + reps = max(1, round(HATCH_KEY_SIZE / size)) + hatch = "".join(c * reps for c in hatch) + else: + hatch = None + rect = Rectangle( (0 + linewidth / 2, 0 + linewidth / 2), width=da.width - linewidth, @@ -191,6 +285,7 @@ def draw_legend( linestyle=data["linetype"], facecolor=facecolor, edgecolor=data["color"], + hatch=hatch, capstyle="projecting", ) da.add_artist(rect) diff --git a/plotnine/geoms/geom_rect.py b/plotnine/geoms/geom_rect.py index 7d53bca6b..c9a72afd7 100644 --- a/plotnine/geoms/geom_rect.py +++ b/plotnine/geoms/geom_rect.py @@ -8,7 +8,7 @@ from .._utils import SIZE_FACTOR, to_rgba from ..doctools import document from .geom import geom -from .geom_polygon import geom_polygon +from .geom_polygon import _add_hatch_overlays, geom_polygon if typing.TYPE_CHECKING: from typing import Any @@ -31,9 +31,29 @@ class geom_rect(geom): {common_parameters} """ + _aesthetics_doc = r""" + {aesthetics_table} + + **Aesthetics Descriptions** + + `hatch` + + : A pattern of strokes drawn over the fill, built from the characters + `-`, `+`, `|`, `/`, `\`, `x`, `X`, `o`, `O`, `.` and `*`. Repeating + a character makes that pattern denser, and characters can be + combined, e.g. `'//'`, `'xx'`, `'.o'`. The strokes take the colour of + the `color` aesthetic, matplotlib's convention for a hatch, and are + black when `color` is not set. + + A legend key is much smaller than the shapes it stands for, so the + pattern is repeated in the key until it reads at that size. Expect a + key to look denser than the shapes when the key is small. + """ + DEFAULT_AES = { "color": None, "fill": "#595959", + "hatch": None, "linetype": "solid", "size": 0.5, "alpha": 1, @@ -123,6 +143,17 @@ def fill_rects( ) ax.add_collection(col) + if "hatch" in data: + _add_hatch_overlays( + ax, + verts, + data["hatch"], + data["color"], + data["alpha"], + params, + PolyCollection, + ) + def _rectangles_to_polygons(df: pd.DataFrame) -> pd.DataFrame: """ diff --git a/plotnine/iapi.py b/plotnine/iapi.py index c41c8de50..cba608ef2 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -98,6 +98,7 @@ class labels_view: color: Optional[str] = None colour: Optional[str] = None fill: Optional[str] = None + hatch: Optional[str] = None linetype: Optional[str] = None shape: Optional[str] = None size: Optional[str] = None diff --git a/plotnine/labels.py b/plotnine/labels.py index 70d2d9db5..3d4632e46 100644 --- a/plotnine/labels.py +++ b/plotnine/labels.py @@ -54,6 +54,11 @@ class labs: Name of the fill legend/colourbar. """ + hatch: str | None = None + """ + Name of the hatch legend. + """ + linetype: str | None = None """ Name of the linetype legend. diff --git a/plotnine/mapping/aes.py b/plotnine/mapping/aes.py index 39f3ab187..79db044fe 100644 --- a/plotnine/mapping/aes.py +++ b/plotnine/mapping/aes.py @@ -46,6 +46,7 @@ class ColorOrColour(Protocol): "colour", "fill", "group", + "hatch", "intercept", "label", "lineheight", @@ -74,6 +75,7 @@ class ColorOrColour(Protocol): "color", "colour", "fill", + "hatch", "linetype", "shape", "size", diff --git a/plotnine/scales/__init__.py b/plotnine/scales/__init__.py index 625065819..405a6ab8c 100644 --- a/plotnine/scales/__init__.py +++ b/plotnine/scales/__init__.py @@ -65,12 +65,19 @@ scale_fill_ordinal, ) +# hatch +from .scale_hatch import ( + scale_hatch, + scale_hatch_discrete, +) + # identity from .scale_identity import ( scale_alpha_identity, scale_color_identity, scale_colour_identity, scale_fill_identity, + scale_hatch_identity, scale_linetype_identity, scale_shape_identity, scale_size_identity, @@ -89,6 +96,7 @@ scale_color_manual, scale_colour_manual, scale_fill_manual, + scale_hatch_manual, scale_linetype_manual, scale_shape_manual, scale_size_manual, @@ -199,6 +207,9 @@ # linetype "scale_linetype", "scale_linetype_discrete", + # hatch + "scale_hatch", + "scale_hatch_discrete", # shape "scale_shape", "scale_shape_discrete", @@ -219,6 +230,7 @@ "scale_colour_identity", "scale_fill_identity", "scale_linetype_identity", + "scale_hatch_identity", "scale_shape_identity", "scale_size_identity", "scale_stroke_identity", @@ -226,6 +238,7 @@ "scale_color_manual", "scale_colour_manual", "scale_fill_manual", + "scale_hatch_manual", "scale_shape_manual", "scale_linetype_manual", "scale_alpha_manual", diff --git a/plotnine/scales/scale_hatch.py b/plotnine/scales/scale_hatch.py new file mode 100644 index 000000000..128c605dd --- /dev/null +++ b/plotnine/scales/scale_hatch.py @@ -0,0 +1,76 @@ +from dataclasses import KW_ONLY, dataclass +from warnings import warn + +from plotnine.scales._runtime_typing import OptionalLegend + +from .._utils.registry import alias +from ..exceptions import PlotnineError, PlotnineWarning +from .scale_continuous import scale_continuous +from .scale_discrete import scale_discrete + +HATCHES = ["/", "\\", "|", "-", "+", "x", "o", "O", ".", "*"] + + +@dataclass +class scale_hatch(scale_discrete[OptionalLegend]): + r""" + Scale for hatch patterns + + Notes + ----- + The available hatch patterns are those standard in matplotlib: + `'/', '\', '|', '-', '+', 'x', 'o', 'O', '.', '*'` + + Repeating a character increases the density of that pattern, and + characters can be combined: `'//'`, `'xx'`, `'.o'`. If you need + per-level control, use [](`~plotnine.scales.scale_hatch_manual`). + """ + + _aesthetics = ["hatch"] + + _: KW_ONLY + guide: OptionalLegend = "legend" + + def __post_init__(self): + from mizani.palettes import manual_pal + + super().__post_init__() + self.palette = manual_pal(HATCHES) + + +@dataclass +class scale_hatch_ordinal(scale_hatch): + """ + Scale for hatch patterns of an ordinal variable + """ + + _aesthetics = ["hatch"] + + def __post_init__(self): + super().__post_init__() + + warn( + "Using hatch for an ordinal variable is not advised.", + PlotnineWarning, + ) + + +class scale_hatch_continuous(scale_continuous): + """ + Hatch scale + + Notes + ----- + A continuous variable cannot be mapped to hatch. `Scales.add_defaults` + swallows this error and leaves the column unscaled, so the message a + user sees comes from the geom, not from here -- the same as + `scale_linetype_continuous`. + """ + + def __init__(self): + raise PlotnineError("A continuous variable cannot be mapped to hatch") + + +@alias +class scale_hatch_discrete(scale_hatch): + pass diff --git a/plotnine/scales/scale_identity.py b/plotnine/scales/scale_identity.py index 44abe3a99..c133befe8 100644 --- a/plotnine/scales/scale_identity.py +++ b/plotnine/scales/scale_identity.py @@ -80,6 +80,16 @@ class scale_linetype_identity(MapTrainMixin, scale_discrete[OptionalLegend]): guide: OptionalLegend = "legend" +@dataclass +class scale_hatch_identity(MapTrainMixin, scale_discrete[OptionalLegend]): + """ + Use hatch value as-is + """ + + _aesthetics = ["hatch"] + guide: OptionalLegend = "legend" + + @dataclass class scale_alpha_identity(MapTrainMixin, scale_continuous[OptionalLegend]): """ diff --git a/plotnine/scales/scale_manual.py b/plotnine/scales/scale_manual.py index 9deaf54bc..7288e8052 100644 --- a/plotnine/scales/scale_manual.py +++ b/plotnine/scales/scale_manual.py @@ -142,6 +142,24 @@ def map(self, x, limits=None): return result +@dataclass +class scale_hatch_manual(_scale_manual): + """ + Custom discrete hatch scale + """ + + _aesthetics = ["hatch"] + values: InitVar[Sequence[Any] | dict[Any, Any]] + """ + Hatch patterns that make up the palette. See + `matplotlib.patches.Patch.set_hatch` for valid patterns + (e.g. '/', '//', 'xx', '.o', '\\\\|'). The values will be + matched with the `limits` of the scale or the `breaks` if + provided. If it is a dict then it should map data values to + hatch patterns. + """ + + @dataclass class scale_alpha_manual(_scale_manual): """ diff --git a/plotnine/typing.py b/plotnine/typing.py index 1a50f6dc3..8bcfdaec0 100644 --- a/plotnine/typing.py +++ b/plotnine/typing.py @@ -103,6 +103,7 @@ def to_pandas(self) -> pd.DataFrame: "color", "colour", "fill", + "hatch", "linetype", "shape", "size", diff --git a/tests/baseline_images/test_geom_bar_col_histogram/col_hatch.png b/tests/baseline_images/test_geom_bar_col_histogram/col_hatch.png new file mode 100644 index 000000000..d2375caf4 Binary files /dev/null and b/tests/baseline_images/test_geom_bar_col_histogram/col_hatch.png differ diff --git a/tests/test_geom_bar_col_histogram.py b/tests/test_geom_bar_col_histogram.py index 8e976db52..8a5bf533b 100644 --- a/tests/test_geom_bar_col_histogram.py +++ b/tests/test_geom_bar_col_histogram.py @@ -1,5 +1,7 @@ import numpy as np import pandas as pd +import pytest +from matplotlib.collections import PolyCollection from plotnine import ( aes, @@ -11,6 +13,7 @@ ggplot, scale_x_sqrt, ) +from plotnine.exceptions import PlotnineError from plotnine.stats.binning import freedman_diaconis_bins n = 10 # Some even number greater than 2 @@ -38,6 +41,35 @@ def test_col(): assert p == "col" +def test_col_hatch(): + df = pd.DataFrame( + {"x": ["a", "b", "c"], "y": [3, 5, 2], "g": ["u", "v", "w"]} + ) + p = ggplot(df, aes("x", "y", fill="g", hatch="g")) + geom_col( + color="black" + ) + + assert p == "col_hatch" + + +def test_col_hatch_continuous_raises(): + df = pd.DataFrame({"x": [1, 2], "y": [1, 2], "g": [0.1, 0.2]}) + p = ggplot(df, aes("x", "y")) + geom_col(aes(hatch="g")) + with pytest.raises(PlotnineError, match="Cannot interpret hatch"): + p.draw() + + +def test_col_no_hatch_no_overlays(): + df = pd.DataFrame({"x": ["a", "b"], "y": [1, 2]}) + p = ggplot(df, aes("x", "y", fill="x")) + geom_col() + fig = p.draw() + cols = [ + c for c in fig.axes[0].collections if isinstance(c, PolyCollection) + ] + assert len(cols) == 1 + assert cols[0].get_hatch() is None + + def test_col_just(): data = pd.DataFrame({"x": range(1, 4), "y": range(1, 4)}) p = (