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 000000000..3c2446815 Binary files /dev/null and b/tests/baseline_images/test_coords/coord_trans_ribbon_edges_curve.png differ 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