From 4d206231d894867fb75ce6f760a0a8a4d9f78e8f Mon Sep 17 00:00:00 2001 From: Florin Gogianu Date: Fri, 7 Aug 2026 09:51:16 +0300 Subject: [PATCH] feat(geom_rect): add a hatch aesthetic Adds a `hatch` aesthetic to geom_rect and everything built on it (geom_bar, geom_col, geom_histogram, geom_tile, geom_bin_2d), settable as a literal (geom_col(hatch="//")) or mapped through a discrete scale (geom_col(aes(hatch="grp"))). A matplotlib Collection takes one hatch pattern for all of its paths, so each distinct pattern gets an overlay PolyCollection with no fill and no border, drawn over the parent. The strokes take the `color` aesthetic, matplotlib's convention for a hatch, and fall back to rcParams["patch.edgecolor"] when color is unset. The overlay's linewidth is zero so the parent keeps sole ownership of the border and its linetype. geom_polygon.draw_group carries the hatch through as well, since geom_rect delegates to it under a non-linear coord. Legend keys repeat the pattern by round(HATCH_KEY_SIZE / key_size), so a small key stays legible while a large one converges on the density the panel draws. Scale family modeled on scale_linetype: - scale_hatch (discrete) / scale_hatch_discrete - scale_hatch_identity - scale_hatch_manual (custom per-level patterns) - scale_hatch_continuous raises PlotnineError, mirroring scale_linetype_continuous `labs(hatch=...)` names the legend. --- doc/_quartodoc.yml | 15 ++- doc/changelog.qmd | 10 ++ plotnine/__init__.py | 8 ++ plotnine/geoms/geom_polygon.py | 95 ++++++++++++++++++ plotnine/geoms/geom_rect.py | 33 +++++- plotnine/iapi.py | 1 + plotnine/labels.py | 5 + plotnine/mapping/aes.py | 2 + plotnine/scales/__init__.py | 13 +++ plotnine/scales/scale_hatch.py | 76 ++++++++++++++ plotnine/scales/scale_identity.py | 10 ++ plotnine/scales/scale_manual.py | 18 ++++ plotnine/typing.py | 1 + .../test_geom_bar_col_histogram/col_hatch.png | Bin 0 -> 10872 bytes tests/test_geom_bar_col_histogram.py | 32 ++++++ 15 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 plotnine/scales/scale_hatch.py create mode 100644 tests/baseline_images/test_geom_bar_col_histogram/col_hatch.png diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 6ac24bdcef..9112478f8d 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 03a71f81d7..1e605c9d14 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 f1dbed8405..510d70e20b 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 b9e3b45f98..fcc8be1e70 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 7d53bca6b9..c9a72afd77 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 c41c8de504..cba608ef29 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 70d2d9db56..3d4632e46e 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 39f3ab1878..79db044fe1 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 6250658197..405a6ab8cb 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 0000000000..128c605ddf --- /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 44abe3a99d..c133befe8b 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 9deaf54bc1..7288e80525 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 1a50f6dc33..8bcfdaec04 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 0000000000000000000000000000000000000000..d2375caf4ebf4dcf92b2da32d28eb3adec794c74 GIT binary patch literal 10872 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 literal 0 HcmV?d00001 diff --git a/tests/test_geom_bar_col_histogram.py b/tests/test_geom_bar_col_histogram.py index 8e976db527..8a5bf533be 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 = (