Skip to content
Open
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
15 changes: 11 additions & 4 deletions doc/_quartodoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -369,7 +377,6 @@ quartodoc:
- position_nudge
- position_stack


- title: Themes
desc: |
Themes control the visual appearance of the non-data elements the plot.
Expand Down Expand Up @@ -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: |
Expand Down
10 changes: 10 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
8 changes: 8 additions & 0 deletions plotnine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions plotnine/geoms/geom_polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
"""
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -191,6 +285,7 @@ def draw_legend(
linestyle=data["linetype"],
facecolor=facecolor,
edgecolor=data["color"],
hatch=hatch,
capstyle="projecting",
)
da.add_artist(rect)
Expand Down
33 changes: 32 additions & 1 deletion plotnine/geoms/geom_rect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
"""
Expand Down
1 change: 1 addition & 0 deletions plotnine/iapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions plotnine/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions plotnine/mapping/aes.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class ColorOrColour(Protocol):
"colour",
"fill",
"group",
"hatch",
"intercept",
"label",
"lineheight",
Expand Down Expand Up @@ -74,6 +75,7 @@ class ColorOrColour(Protocol):
"color",
"colour",
"fill",
"hatch",
"linetype",
"shape",
"size",
Expand Down
13 changes: 13 additions & 0 deletions plotnine/scales/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -199,6 +207,9 @@
# linetype
"scale_linetype",
"scale_linetype_discrete",
# hatch
"scale_hatch",
"scale_hatch_discrete",
# shape
"scale_shape",
"scale_shape_discrete",
Expand All @@ -219,13 +230,15 @@
"scale_colour_identity",
"scale_fill_identity",
"scale_linetype_identity",
"scale_hatch_identity",
"scale_shape_identity",
"scale_size_identity",
"scale_stroke_identity",
# manual
"scale_color_manual",
"scale_colour_manual",
"scale_fill_manual",
"scale_hatch_manual",
"scale_shape_manual",
"scale_linetype_manual",
"scale_alpha_manual",
Expand Down
Loading
Loading