From ebf5cb3ee4d23aa13f98f7c081d0e03d9210134c Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 11 Aug 2026 23:40:19 +0300 Subject: [PATCH] fix(coord): interpolate every position aesthetic when munching Path munching interpolated `x` and `y` but copied all other columns from each segment's starting point. Position aesthetics such as `ymin` and `ymax` also define geometry, so copying them made transformed ribbon edges step between observations. Interpolate every position aesthetic present in the data so non-linear coordinates preserve ribbons and other interval-based geometry. --- doc/changelog.qmd | 6 +++ plotnine/coords/coord.py | 42 ++++++++++-------- .../coord_trans_ribbon_edges_curve.png | Bin 0 -> 7715 bytes tests/test_coords.py | 35 +++++++++++++++ 4 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 tests/baseline_images/test_coords/coord_trans_ribbon_edges_curve.png diff --git a/doc/changelog.qmd b/doc/changelog.qmd index cc75c84f2..7d4e65785 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -128,6 +128,12 @@ title: Changelog rectangle is now drawn as its own polygon instead of merging a bar's segments into a single path. +- In a non-linear coordinate system (e.g. [](:class:`~plotnine.coord_trans`)), + both edges of a [](:class:`~plotnine.geom_ribbon`) or + [](:class:`~plotnine.geom_area`) now curve. Previously each edge held the + value of the point its segment started from, so the band was drawn as a + staircase. + - 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/coords/coord.py b/plotnine/coords/coord.py index a86abb6ad..91b1ed3dd 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -8,6 +8,7 @@ from .._utils import OPPOSITE_SIDE from ..iapi import panel_ranges +from ..mapping.aes import POSITION_AESTHETICS if typing.TYPE_CHECKING: from typing import Any, Sequence @@ -408,39 +409,42 @@ def interp(start: int, end: int, n: int) -> FloatArray: def munch_data(data: pd.DataFrame, dist: FloatArray) -> pd.DataFrame: """ - Breakup path into small segments + Subdivide path segments and interpolate their position aesthetics """ - x, y = data["x"], data["y"] segment_length = 0.01 - # How many endpoints for each old segment, - # not counting the last one + # Count new points per segment, excluding the final endpoint. dist[np.isnan(dist)] = 1 extra = np.maximum(np.floor(dist / segment_length), 1) extra = extra.astype(int) - # Generate extra pieces for x and y values - # The final point must be manually inserted at the end - x = [interp(start, end, n) for start, end, n in zip(x[:-1], x[1:], extra)] - y = [interp(start, end, n) for start, end, n in zip(y[:-1], y[1:], extra)] - x.append(data["x"].iloc[-1]) - y.append(data["y"].iloc[-1]) - x = np.hstack(x) - y = np.hstack(y) - - # Replicate other aesthetics: defined by start point - # but also must include final point + # Every position aesthetic defines path geometry. Replicating `ymin` and + # `ymax`, for example, would turn curved ribbon edges into steps. + position_columns = [c for c in data.columns if c in POSITION_AESTHETICS] + + # Append the final endpoint after interpolating each segment. + interpolated = {} + for col in position_columns: + values = data[col].to_numpy() + pieces = [ + interp(start, end, n) + for start, end, n in zip(values[:-1], values[1:], extra) + ] + pieces.append(values[-1:]) + interpolated[col] = np.hstack(pieces) + + # Hold non-position aesthetics at each segment's starting value, then + # append the final observation. idx = np.hstack( [ np.repeat(data.index[:-1], extra), len(data) - 1, - # data.index[-1] # TODO: Maybe not ] ) - munched = data.loc[idx, list(data.columns.difference(["x", "y"]))] - munched["x"] = x - munched["y"] = y + munched = data.loc[idx, list(data.columns.difference(position_columns))] + for col, values in interpolated.items(): + munched[col] = values munched.reset_index(drop=True, inplace=True) return munched diff --git a/tests/baseline_images/test_coords/coord_trans_ribbon_edges_curve.png b/tests/baseline_images/test_coords/coord_trans_ribbon_edges_curve.png new file mode 100644 index 0000000000000000000000000000000000000000..3c24468157611188530cf1636d43823be396f9db GIT binary patch literal 7715 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 literal 0 HcmV?d00001 diff --git a/tests/test_coords.py b/tests/test_coords.py index 4d2e1fde6..08336d265 100644 --- a/tests/test_coords.py +++ b/tests/test_coords.py @@ -16,9 +16,11 @@ geom_line, geom_point, geom_polygon, + geom_ribbon, ggplot, xlim, ) +from plotnine.coords.coord import munch_data from plotnine.data import mtcars n = 10 # Some even number greater than 2 @@ -92,6 +94,39 @@ def test_coord_trans_munches_polygon_closing_edge(): assert p == "coord_trans_munches_polygon_closing_edge" +def test_munch_interpolates_every_position_aesthetic(): + # Ribbon edges use `ymin` and `ymax` as path coordinates, so both must + # vary within each munched segment. + data = pd.DataFrame( + { + "x": [0.0, 1.0], + "y": [3.0, 8.0], + "ymin": [1.0, 6.0], + "ymax": [5.0, 10.0], + "group": [1, 1], + } + ) + + munched = munch_data(data, np.array([1.0])) + + assert len(munched) > len(data) + for column in ("x", "y", "ymin", "ymax"): + values = munched[column].to_numpy() + assert np.all(np.diff(values) > 0), f"{column} was not interpolated" + + +def test_coord_trans_ribbon_edges_curve(): + # A smooth transformed ribbon exposes piecewise-constant `ymin` and + # `ymax` values as stepped edges. + data = pd.DataFrame({"x": range(6), "y": [3.0, 8, 5, 9, 4, 7]}) + p = ( + ggplot(data, aes("x", ymin="y - 2", ymax="y + 2")) + + geom_ribbon(alpha=0.5) + + coord_trans(y="sqrt") + ) + assert p == "coord_trans_ribbon_edges_curve" + + def test_coord_trans_stacked_bars_have_no_spikes(): # Each stacked segment must be its own polygon. If they merge into one # path, the join between consecutive segments becomes a diagonal that