diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 2e70d27599..b17fb867d1 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -139,6 +139,12 @@ title: Changelog `upper`, `lower` and `both` outlines follow the transformed band edges, and `full` outlines no longer raise an error. +- [](:class:`~plotnine.annotation_logticks`) and + [](:class:`~plotnine.annotation_stripes`) now render correctly in non-linear + coordinate systems such as [](:class:`~plotnine.coord_trans`). Previously, + log tick positions were transformed twice, which misplaced or removed ticks, + and stripes raised an error. + - The space between facet panels now accounts for the margins of the axis text, so with free scales large margins no longer push the tick labels into the neighbouring panel. diff --git a/plotnine/geoms/annotation_logticks.py b/plotnine/geoms/annotation_logticks.py index 57494b23c8..4d68bd8f83 100644 --- a/plotnine/geoms/annotation_logticks.py +++ b/plotnine/geoms/annotation_logticks.py @@ -12,7 +12,7 @@ from ..scales.scale_continuous import scale_continuous as ScaleContinuous from .annotate import annotate from .geom_path import geom_path -from .geom_rug import geom_rug +from .geom_rug import geom_rug, stroke_rugs if typing.TYPE_CHECKING: from typing import Any, Literal, Optional, Sequence @@ -21,7 +21,6 @@ from plotnine.coords.coord import coord from plotnine.facets.layout import Layout - from plotnine.geoms.geom import geom from plotnine.iapi import panel_view from plotnine.typing import AnyArray @@ -58,26 +57,21 @@ def _check_log_scale( base: Optional[float], sides: str, panel_params: panel_view, - coord: coord, ) -> tuple[float, float]: """ Check the log transforms Parameters ---------- - base : float | None + base : Base of the logarithm in which the ticks will be calculated. If `None`, the base of the log transform the scale will be used. - sides : str, default="bl" - Sides onto which to draw the marks. Any combination - chosen from the characters `btlr`, for *bottom*, *top*, - *left* or *right* side marks. If `coord_flip()` is used, - these are the sides *before* the flip. - panel_params : panel_view + sides : + Panel sides to mark, using any combination of `b`, `t`, `l`, + and `r`. Resolve any axis flip before calling. + panel_params : `x` and `y` view scale values. - coord : coord - Coordinate (e.g. coord_cartesian) system of the geom. Returns ------- @@ -111,10 +105,6 @@ def get_base(sc, ubase: Optional[float]) -> float: x_scale = panel_params.x.scale y_scale = panel_params.y.scale - if isinstance(coord, coord_flip): - x_scale, y_scale = y_scale, x_scale - base_x, base_y = base_y, base_x - if "t" in sides or "b" in sides: base_x = get_base(x_scale, base) @@ -191,35 +181,30 @@ def draw_panel( "linetype": params["linetype"], } + # `sides` names edges before `coord_flip`. Convert it to the + # displayed panel edges used below. + if isinstance(coord, coord_flip): + sides = sides.translate(str.maketrans("tblr", "rlbt")) + def _draw( - geom: geom, axis: Literal["x", "y"], tick_positions: tuple[AnyArray, AnyArray, AnyArray], ): for position, length in zip(tick_positions, lengths): data = pd.DataFrame({axis: position, **_aesthetics}) - params["length"] = length - geom.draw_group(data, panel_params, coord, ax, params) - - if isinstance(coord, coord_flip): - tick_range_x = panel_params.y.range - tick_range_y = panel_params.x.range - else: - tick_range_x = panel_params.x.range - tick_range_y = panel_params.y.range + stroke_rugs(data, panel_params, ax, params, sides, length) - # these are already flipped iff coord_flip base_x, base_y = self._check_log_scale( - params["base"], sides, panel_params, coord + params["base"], sides, panel_params ) if "b" in sides or "t" in sides: - tick_positions = self._calc_ticks(tick_range_x, base_x) - _draw(self, "x", tick_positions) + tick_positions = self._calc_ticks(panel_params.x.range, base_x) + _draw("x", tick_positions) if "l" in sides or "r" in sides: - tick_positions = self._calc_ticks(tick_range_y, base_y) - _draw(self, "y", tick_positions) + tick_positions = self._calc_ticks(panel_params.y.range, base_y) + _draw("y", tick_positions) class annotation_logticks(annotate): @@ -234,8 +219,8 @@ class annotation_logticks(annotate): sides : Sides onto which to draw the marks. Any combination chosen from the characters `btlr`, for *bottom*, *top*, - *left* or *right* side marks. If `coord_flip()` is used, - these are the sides *after* the flip. + *left* or *right* side marks. With `coord_flip()`, specify + sides before the flip. alpha : Transparency of the ticks color : diff --git a/plotnine/geoms/annotation_stripes.py b/plotnine/geoms/annotation_stripes.py index e4c8254c76..d20807eb19 100644 --- a/plotnine/geoms/annotation_stripes.py +++ b/plotnine/geoms/annotation_stripes.py @@ -11,7 +11,7 @@ from .annotate import annotate from .geom import geom from .geom_polygon import geom_polygon -from .geom_rect import geom_rect +from .geom_rect import fill_rects if typing.TYPE_CHECKING: from typing import Any, Literal, Sequence @@ -173,7 +173,7 @@ def draw_group( fill[0] = fill[1] fill[-1] = fill[-2] - if direction != "vertical": + if axis != "x": xmin, xmax, ymin, ymax = ymin, ymax, xmin, xmax data = pd.DataFrame( @@ -190,4 +190,4 @@ def draw_group( } ) - return geom_rect.draw_group(data, panel_params, coord, ax, params) + fill_rects(data, ax, params) diff --git a/plotnine/geoms/geom_rect.py b/plotnine/geoms/geom_rect.py index 9b52ecf8ea..7d53bca6b9 100644 --- a/plotnine/geoms/geom_rect.py +++ b/plotnine/geoms/geom_rect.py @@ -75,32 +75,53 @@ def draw_group( ax: Axes, params: dict[str, Any], ): - from matplotlib.collections import PolyCollection - data = coord.transform(data, panel_params, munch=True) - linewidth = data["size"] * SIZE_FACTOR + fill_rects(data, ax, params) + + +def fill_rects( + data: pd.DataFrame, + ax: Axes, + params: dict[str, Any], +) -> None: + """ + Draw rectangles whose bounds use panel coordinates + + Parameters + ---------- + data : + Rectangle aesthetics with panel-coordinate `xmin`, `xmax`, + `ymin`, and `ymax` bounds. + ax : + Axes to draw on. + params : + Geom and stat parameters that control rectangle appearance. + """ + from matplotlib.collections import PolyCollection - limits = zip(data["xmin"], data["xmax"], data["ymin"], data["ymax"]) + linewidth = data["size"] * SIZE_FACTOR - verts = [[(l, b), (l, t), (r, t), (r, b)] for (l, r, b, t) in limits] + limits = zip(data["xmin"], data["xmax"], data["ymin"], data["ymax"]) - fill = to_rgba(data["fill"], data["alpha"]) - color = data["color"] + verts = [[(l, b), (l, t), (r, t), (r, b)] for (l, r, b, t) in limits] - # prevent unnecessary borders - if all(color.isna()): - color = "none" + fill = to_rgba(data["fill"], data["alpha"]) + color = data["color"] - col = PolyCollection( - verts, - facecolors=fill, - edgecolors=color, - linestyles=data["linetype"], - linewidths=linewidth, - zorder=params["zorder"], - rasterized=params["raster"], - ) - ax.add_collection(col) + # prevent unnecessary borders + if all(color.isna()): + color = "none" + + col = PolyCollection( + verts, + facecolors=fill, + edgecolors=color, + linestyles=data["linetype"], + linewidths=linewidth, + zorder=params["zorder"], + rasterized=params["raster"], + ) + ax.add_collection(col) def _rectangles_to_polygons(df: pd.DataFrame) -> pd.DataFrame: diff --git a/plotnine/geoms/geom_rug.py b/plotnine/geoms/geom_rug.py index 33711c9953..808338f042 100644 --- a/plotnine/geoms/geom_rug.py +++ b/plotnine/geoms/geom_rug.py @@ -57,62 +57,91 @@ def draw_group( ax: Axes, params: dict[str, Any], ): - from matplotlib.collections import LineCollection - data = coord.transform(data, panel_params) sides = params["sides"] # coord_flip does not flip the side(s) on which the rugs # are plotted. We do the flipping here if isinstance(coord, coord_flip): - t = str.maketrans("tblr", "rlbt") - sides = sides.translate(t) - - linewidth = data["size"] * SIZE_FACTOR - - has_x = "x" in data.columns - has_y = "y" in data.columns - - if has_x or has_y: - n = len(data) - else: - return - - rugs = [] - xmin, xmax = panel_params.x.range - ymin, ymax = panel_params.y.range - xheight = (xmax - xmin) * params["length"] - yheight = (ymax - ymin) * params["length"] - - if has_x: - x = cast("FloatArray", np.repeat(data["x"].to_numpy(), 2)) - - if "b" in sides: - y = np.tile([ymin, ymin + yheight], n) - rugs.extend(make_line_segments(x, y, ispath=False)) - - if "t" in sides: - y = np.tile([ymax - yheight, ymax], n) - rugs.extend(make_line_segments(x, y, ispath=False)) - - if has_y: - y = cast("FloatArray", np.repeat(data["y"].to_numpy(), 2)) - - if "l" in sides: - x = np.tile([xmin, xmin + xheight], n) - rugs.extend(make_line_segments(x, y, ispath=False)) - - if "r" in sides: - x = np.tile([xmax - xheight, xmax], n) - rugs.extend(make_line_segments(x, y, ispath=False)) - - color = to_rgba(data["color"], data["alpha"]) - coll = LineCollection( - rugs, - edgecolor=color, - linewidth=linewidth, - linestyle=data["linetype"], - zorder=params["zorder"], - rasterized=params["raster"], - ) - ax.add_collection(coll) + sides = sides.translate(str.maketrans("tblr", "rlbt")) + + stroke_rugs(data, panel_params, ax, params, sides, params["length"]) + + +def stroke_rugs( + data: pd.DataFrame, + panel_params: panel_view, + ax: Axes, + params: dict[str, Any], + sides: str, + length: float, +) -> None: + """ + Draw rug marks in panel coordinates + + Parameters + ---------- + data : + Rug-mark aesthetics. Include `x`, `y`, or both; position values + must use panel coordinates. + panel_params : + Panel ranges used to determine the mark endpoints. + ax : + Axes to draw on. + params : + Geom and stat parameters that control line appearance. + sides : + Panel sides to mark, using any combination of `b`, `t`, `l`, and + `r`. Resolve any axis flip before calling. + length : + Length of each mark as a fraction of the panel width or height. + """ + from matplotlib.collections import LineCollection + + linewidth = data["size"] * SIZE_FACTOR + + has_x = "x" in data.columns + has_y = "y" in data.columns + + if not (has_x or has_y): + return + + n = len(data) + rugs = [] + xmin, xmax = panel_params.x.range + ymin, ymax = panel_params.y.range + xheight = (xmax - xmin) * length + yheight = (ymax - ymin) * length + + if has_x: + x = cast("FloatArray", np.repeat(data["x"].to_numpy(), 2)) + + if "b" in sides: + y = np.tile([ymin, ymin + yheight], n) + rugs.extend(make_line_segments(x, y, ispath=False)) + + if "t" in sides: + y = np.tile([ymax - yheight, ymax], n) + rugs.extend(make_line_segments(x, y, ispath=False)) + + if has_y: + y = cast("FloatArray", np.repeat(data["y"].to_numpy(), 2)) + + if "l" in sides: + x = np.tile([xmin, xmin + xheight], n) + rugs.extend(make_line_segments(x, y, ispath=False)) + + if "r" in sides: + x = np.tile([xmax - xheight, xmax], n) + rugs.extend(make_line_segments(x, y, ispath=False)) + + color = to_rgba(data["color"], data["alpha"]) + coll = LineCollection( + rugs, + edgecolor=color, + linewidth=linewidth, + linestyle=data["linetype"], + zorder=params["zorder"], + rasterized=params["raster"], + ) + ax.add_collection(coll) diff --git a/tests/baseline_images/test_annotation_logticks/annotation_logticks_coord_trans.png b/tests/baseline_images/test_annotation_logticks/annotation_logticks_coord_trans.png new file mode 100644 index 0000000000..2a6ef67256 Binary files /dev/null and b/tests/baseline_images/test_annotation_logticks/annotation_logticks_coord_trans.png differ diff --git a/tests/baseline_images/test_annotation_stripes/annotation_stripes_coord_trans.png b/tests/baseline_images/test_annotation_stripes/annotation_stripes_coord_trans.png new file mode 100644 index 0000000000..e5d77df8f5 Binary files /dev/null and b/tests/baseline_images/test_annotation_stripes/annotation_stripes_coord_trans.png differ diff --git a/tests/test_annotation_logticks.py b/tests/test_annotation_logticks.py index 632ea8977d..f36ba45f26 100644 --- a/tests/test_annotation_logticks.py +++ b/tests/test_annotation_logticks.py @@ -7,6 +7,7 @@ aes, annotation_logticks, coord_flip, + coord_trans, element_line, facet_wrap, geom_point, @@ -37,6 +38,20 @@ def test_annotation_logticks(): assert p == "annotation_logticks" +def test_annotation_logticks_coord_trans(): + # Major grid lines and long log ticks must coincide. + p = ( + ggplot(data, aes("x", "x")) + + annotation_logticks(sides="b", size=0.75) + + geom_point() + + scale_x_continuous(breaks=[1, 10, 100, 1000]) + + coord_trans(x="log10") + + theme(panel_grid_major=element_line(color="red")) + ) + with pytest.warns(PlotnineWarning): + assert p == "annotation_logticks_coord_trans" + + def test_annotation_logticks_faceting(): n = len(data) data2 = pd.DataFrame( diff --git a/tests/test_annotation_stripes.py b/tests/test_annotation_stripes.py index c3afae15bd..ccdb510083 100644 --- a/tests/test_annotation_stripes.py +++ b/tests/test_annotation_stripes.py @@ -6,6 +6,7 @@ aes, annotation_stripes, coord_flip, + coord_trans, facet_wrap, geom_point, geom_vline, @@ -122,3 +123,15 @@ def test_annotation_stripes_single_stripe(): ) assert p == "annotation_stripes_single_stripe" + + +def test_annotation_stripes_coord_trans(): + data2 = data.assign(y=10.0 ** (data["y"] % 3)) + p = ( + ggplot(data2) + + annotation_stripes(fill_range="no") + + geom_point(aes("factor(x)", "y")) + + coord_trans(y="log10") + ) + + assert p == "annotation_stripes_coord_trans"