diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 7d4e657859..2e70d27599 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -134,6 +134,11 @@ title: Changelog value of the point its segment started from, so the band was drawn as a staircase. +- [](:class:`~plotnine.geom_ribbon`) and [](:class:`~plotnine.geom_area`) now + draw every outline type correctly in non-linear coordinate systems. The + `upper`, `lower` and `both` outlines follow the transformed band edges, and + `full` outlines no longer raise 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/geom_path.py b/plotnine/geoms/geom_path.py index 7f0fa12b84..42bf268f75 100644 --- a/plotnine/geoms/geom_path.py +++ b/plotnine/geoms/geom_path.py @@ -154,17 +154,7 @@ def draw_group( params: dict[str, Any], ): data = coord.transform(data, panel_params, munch=True) - data["linewidth"] = data["size"] * SIZE_FACTOR - - if "constant" in params: - constant: bool = params.pop("constant") - else: - constant = len(np.unique(data["group"].to_numpy())) == 1 - - if not constant: - _draw_segments(data, ax, params) - else: - _draw_lines(data, ax, params) + constant = stroke_paths(data, ax, params, params.get("constant")) if "arrow" in params and params["arrow"]: params["arrow"].draw( @@ -451,6 +441,49 @@ def get_paths( return paths +def stroke_paths( + data: pd.DataFrame, + ax: Axes, + params: dict[str, Any], + constant: bool | None = None, +) -> bool: + """ + Draw paths from panel-coordinate data + + Parameters + ---------- + data : + Path data in panel coordinates. Must include a `size` column. + The function adds a `linewidth` column in place so subsequent + arrowheads use the same width. + ax : + Axes on which to draw the paths. + params : + Geom and stat parameters used to style the paths. + constant : + Whether aesthetics remain constant along each path. If `False`, + draw each pair of adjacent points as a separate segment. If + `None`, infer `True` when the data contains one group. + + Returns + ------- + : + Whether the paths were drawn with constant aesthetics. Callers + use this value to draw matching arrowheads. + """ + data["linewidth"] = data["size"] * SIZE_FACTOR + + if constant is None: + constant = len(np.unique(data["group"].to_numpy())) == 1 + + if constant: + _draw_lines(data, ax, params) + else: + _draw_segments(data, ax, params) + + return constant + + def _draw_segments(data: pd.DataFrame, ax: Axes, params: dict[str, Any]): """ Draw independent line segments between all the diff --git a/plotnine/geoms/geom_ribbon.py b/plotnine/geoms/geom_ribbon.py index 0d305b852a..0469e038b2 100644 --- a/plotnine/geoms/geom_ribbon.py +++ b/plotnine/geoms/geom_ribbon.py @@ -7,7 +7,7 @@ from ..doctools import document from ..exceptions import PlotnineError from .geom import geom -from .geom_path import geom_path +from .geom_path import stroke_paths from .geom_polygon import geom_polygon if typing.TYPE_CHECKING: @@ -75,12 +75,11 @@ def handle_na(self, data: pd.DataFrame) -> pd.DataFrame: return data def setup_data(self, data: pd.DataFrame) -> pd.DataFrame: - # The outlines need x and y coordinates - if self.params["outline_type"] in ("upper", "lower", "both"): - if "xmax" in data and "x" not in data: - data["x"] = data["xmax"] - if "ymax" in data and "y" not in data: - data["y"] = data["ymax"] + # Coordinate munching requires `x` and `y` for every outline type. + if "xmax" in data and "x" not in data: + data["x"] = data["xmax"] + if "ymax" in data and "y" not in data: + data["y"] = data["ymax"] return data @staticmethod @@ -154,12 +153,11 @@ def draw_unit( # Alpha does not affect the outlines data["alpha"] = 1 - geom_ribbon._draw_outline(data, panel_params, coord, ax, params) + geom_ribbon._draw_outline(data, coord, ax, params) @staticmethod def _draw_outline( data: pd.DataFrame, - panel_params: panel_view, coord: coord, ax: Axes, params: dict[str, Any], @@ -169,25 +167,16 @@ def _draw_outline( if outline_type == "full": return - x, y = "x", "y" - if isinstance(coord, coord_flip): - x, y = y, x - data[x], data[y] = data[y], data[x] + # The data is already in panel coordinates. After `coord_flip`, + # the ribbon bounds are `xmin` and `xmax`. + bounds = "x" if isinstance(coord, coord_flip) else "y" + # Each call receives one ribbon group, so an outline forms one path + # with constant aesthetics. if outline_type in ("lower", "both"): - geom_path.draw_group( - data.assign(y=data[f"{y}min"]), - panel_params, - coord, - ax, - params, - ) + lower = data.assign(**{bounds: data[f"{bounds}min"]}) + stroke_paths(lower, ax, params, constant=True) if outline_type in ("upper", "both"): - geom_path.draw_group( - data.assign(y=data[f"{y}max"]), - panel_params, - coord, - ax, - params, - ) + upper = data.assign(**{bounds: data[f"{bounds}max"]}) + stroke_paths(upper, ax, params, constant=True) diff --git a/tests/baseline_images/test_geom_ribbon_area/ribbon_outline_type_coord_trans.png b/tests/baseline_images/test_geom_ribbon_area/ribbon_outline_type_coord_trans.png new file mode 100644 index 0000000000..47c54b818e Binary files /dev/null and b/tests/baseline_images/test_geom_ribbon_area/ribbon_outline_type_coord_trans.png differ diff --git a/tests/test_geom_ribbon_area.py b/tests/test_geom_ribbon_area.py index f22a1ca24f..5af00b14db 100644 --- a/tests/test_geom_ribbon_area.py +++ b/tests/test_geom_ribbon_area.py @@ -5,6 +5,7 @@ aes, after_stat, coord_flip, + coord_trans, facet_wrap, geom_area, geom_line, @@ -159,3 +160,8 @@ def test_ribbon_outline_type(self): def test_ribbon_outline_type_coord_flip(self): assert self.p + coord_flip() == "ribbon_outline_type_coord_flip" + + def test_ribbon_outline_type_coord_trans(self): + assert ( + self.p + coord_trans(y="sqrt") == "ribbon_outline_type_coord_trans" + )