From acd1cfe6f3c5c0384089d42904525a69e29712c7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 10:57:17 +1000 Subject: [PATCH 1/2] Add histtype option for ridgeline histograms --- ultraplot/axes/plot.py | 216 +++++++++++++++---- ultraplot/tests/test_statistical_plotting.py | 20 ++ 2 files changed, 190 insertions(+), 46 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 74bfde749..4c2685651 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -6342,6 +6342,7 @@ def _apply_ridgeline( points=200, hist=False, bins="auto", + histtype=None, fill=True, alpha=1.0, linewidth=1.5, @@ -6383,6 +6384,10 @@ def _apply_ridgeline( bins : int or sequence or str, default: 'auto' Bin specification for histograms. Passed to numpy.histogram. Only used when hist=True. + histtype : {'fill', 'bar', 'step', 'stepfilled'}, optional + Rendering style for histogram ridgelines. Defaults to ``'fill'``, + which uses a filled ridge curve. ``'bar'`` draws histogram bars. + Only used when hist=True. fill : bool, default: True Whether to fill the area under each curve. alpha : float, default: 1.0 @@ -6446,6 +6451,12 @@ def _apply_ridgeline( # Calculate KDE or histogram for each distribution ridges = [] + if hist and histtype is None: + histtype = "fill" + if hist: + allowed = ("fill", "bar", "step", "stepfilled") + if histtype not in allowed: + raise ValueError(f"Invalid histtype={histtype!r}. Options are {allowed}.") for i, dist in enumerate(data): dist = np.asarray(dist).ravel() dist = dist[~np.isnan(dist)] # Remove NaNs @@ -6465,7 +6476,15 @@ def _apply_ridgeline( # Extend to bin edges for proper fill x_extended = np.concatenate([[bin_edges[0]], x, [bin_edges[-1]]]) y_extended = np.concatenate([[0], counts, [0]]) - ridges.append((x_extended, y_extended)) + ridges.append( + { + "x": x_extended, + "y": y_extended, + "hist": True, + "counts": counts, + "bin_edges": bin_edges, + } + ) except Exception as e: warnings._warn_ultraplot( f"Histogram failed for distribution {i}: {e}, skipping" @@ -6481,7 +6500,7 @@ def _apply_ridgeline( x_margin = x_range * 0.1 # 10% margin x = np.linspace(x_min - x_margin, x_max + x_margin, points) y = kde(x) - ridges.append((x, y)) + ridges.append({"x": x, "y": y, "hist": False}) except Exception as e: warnings._warn_ultraplot( f"KDE failed for distribution {i}: {e}, skipping" @@ -6524,7 +6543,7 @@ def _apply_ridgeline( ) else: # Categorical (evenly-spaced) positioning mode - max_height = max(y.max() for x, y in ridges) + max_height = max(ridge["y"].max() for ridge in ridges) spacing = max_height * (1 + overlap) artists = [] @@ -6532,7 +6551,10 @@ def _apply_ridgeline( base_zorder = kwargs.pop("zorder", 2) n_ridges = len(ridges) - for i, (x, y) in enumerate(ridges): + for i, ridge in enumerate(ridges): + x = ridge["x"] + y = ridge["y"] + is_hist = ridge.get("hist", False) if continuous_mode: # Continuous mode: scale to specified height and position at coordinate y_max = y.max() @@ -6544,7 +6566,7 @@ def _apply_ridgeline( y_plot = y_scaled + offset else: # Categorical mode: normalize and space evenly - y_normalized = y / max_height + y_normalized = y / max_height if max_height > 0 else y offset = i * spacing y_plot = y_normalized + offset @@ -6554,68 +6576,170 @@ def _apply_ridgeline( fill_zorder = base_zorder + (n_ridges - i - 1) * 2 outline_zorder = fill_zorder + 1 - if vert: - # Traditional horizontal ridges - if fill: - # Fill without edge - poly = self.fill_between( - x, - offset, - y_plot, - facecolor=colors[i], + if is_hist and histtype == "bar": + counts = ridge["counts"] + bin_edges = ridge["bin_edges"] + if continuous_mode: + y_max = y.max() + scale = (heights[i] / y_max) if y_max > 0 else 1.0 + bar_heights = counts * scale + else: + scale = (1.0 / max_height) if max_height > 0 else 1.0 + bar_heights = counts * scale + if vert: + poly = self.bar( + bin_edges[:-1], + bar_heights, + width=np.diff(bin_edges), + bottom=offset, + align="edge", + color=colors[i], alpha=alpha, - edgecolor="none", + edgecolor=edgecolor, + linewidth=linewidth, label=labels[i], zorder=fill_zorder, ) - # Draw outline on top (excluding baseline) - self.plot( - x, - y_plot, - color=edgecolor, - linewidth=linewidth, - zorder=outline_zorder, - ) else: - poly = self.plot( - x, - y_plot, + poly = self.barh( + bin_edges[:-1], + bar_heights, + height=np.diff(bin_edges), + left=offset, + align="edge", color=colors[i], - linewidth=linewidth, - label=labels[i], - zorder=outline_zorder, - )[0] - else: - # Vertical ridges - if fill: - # Fill without edge - poly = self.fill_betweenx( - x, - offset, - y_plot, - facecolor=colors[i], alpha=alpha, - edgecolor="none", + edgecolor=edgecolor, + linewidth=linewidth, label=labels[i], zorder=fill_zorder, ) - # Draw outline on top (excluding baseline) + elif is_hist and histtype in ("step", "stepfilled"): + if vert: + if histtype == "stepfilled": + poly = self.fill_between( + x, + offset, + y_plot, + facecolor=colors[i], + alpha=alpha, + edgecolor="none", + label=labels[i], + step="mid", + zorder=fill_zorder, + ) + else: + poly = self.plot( + x, + y_plot, + color=edgecolor, + linewidth=linewidth, + label=labels[i], + drawstyle="steps-mid", + zorder=outline_zorder, + )[0] self.plot( - y_plot, x, + y_plot, color=edgecolor, linewidth=linewidth, + drawstyle="steps-mid", zorder=outline_zorder, ) else: - poly = self.plot( + if histtype == "stepfilled": + poly = self.fill_betweenx( + x, + offset, + y_plot, + facecolor=colors[i], + alpha=alpha, + edgecolor="none", + label=labels[i], + step="mid", + zorder=fill_zorder, + ) + else: + poly = self.plot( + y_plot, + x, + color=edgecolor, + linewidth=linewidth, + label=labels[i], + drawstyle="steps-mid", + zorder=outline_zorder, + )[0] + self.plot( y_plot, x, - color=colors[i], + color=edgecolor, linewidth=linewidth, - label=labels[i], + drawstyle="steps-mid", zorder=outline_zorder, - )[0] + ) + else: + if vert: + # Traditional horizontal ridges + if fill: + # Fill without edge + poly = self.fill_between( + x, + offset, + y_plot, + facecolor=colors[i], + alpha=alpha, + edgecolor="none", + label=labels[i], + zorder=fill_zorder, + ) + # Draw outline on top (excluding baseline) + self.plot( + x, + y_plot, + color=edgecolor, + linewidth=linewidth, + zorder=outline_zorder, + ) + else: + poly = self.plot( + x, + y_plot, + color=colors[i], + linewidth=linewidth, + label=labels[i], + zorder=outline_zorder, + )[0] + else: + # Vertical ridges + if fill: + # Fill without edge + poly = self.fill_betweenx( + x, + offset, + y_plot, + facecolor=colors[i], + alpha=alpha, + edgecolor="none", + label=labels[i], + zorder=fill_zorder, + ) + # Draw outline on top (excluding baseline) + self.plot( + y_plot, + x, + color=edgecolor, + linewidth=linewidth, + zorder=outline_zorder, + ) + else: + poly = self.plot( + y_plot, + x, + color=colors[i], + linewidth=linewidth, + label=labels[i], + zorder=outline_zorder, + )[0] artists.append(poly) diff --git a/ultraplot/tests/test_statistical_plotting.py b/ultraplot/tests/test_statistical_plotting.py index cb73757c3..2c82b14ff 100644 --- a/ultraplot/tests/test_statistical_plotting.py +++ b/ultraplot/tests/test_statistical_plotting.py @@ -271,6 +271,26 @@ def test_ridgeline_histogram_colormap(rng): return fig +def test_ridgeline_histogram_bar(rng): + """ + Test ridgeline plot with histogram bars. + """ + data = [rng.normal(i, 1, 300) for i in range(4)] + labels = [f"Group {i+1}" for i in range(4)] + + fig, ax = uplt.subplots() + artists = ax.ridgeline( + data, + labels=labels, + overlap=0.5, + hist=True, + histtype="bar", + bins=12, + ) + assert len(artists) == len(data) + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_ridgeline_comparison_kde_vs_hist(rng): """ From 3b0e57daa4cbae8614cfe9d3f95964d3dd04d8b7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:04:42 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/axes/plot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 4c2685651..213135187 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -6456,7 +6456,9 @@ def _apply_ridgeline( if hist: allowed = ("fill", "bar", "step", "stepfilled") if histtype not in allowed: - raise ValueError(f"Invalid histtype={histtype!r}. Options are {allowed}.") + raise ValueError( + f"Invalid histtype={histtype!r}. Options are {allowed}." + ) for i, dist in enumerate(data): dist = np.asarray(dist).ravel() dist = dist[~np.isnan(dist)] # Remove NaNs