From b95612903cfbb27a0ab995787e74ed29e25cd3b6 Mon Sep 17 00:00:00 2001 From: Daniel Nagel Date: Thu, 7 May 2026 13:26:38 +0200 Subject: [PATCH 1/2] chore: improve algorithm to deduplicate legend. --- CHANGELOG.md | 2 ++ src/prettypyplot/pyplot.py | 73 +++++++++++++++++++++++++++++++++----- tests/test_pyplot.py | 33 ++++++++++++++++- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bfb44..811f264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. The format [//]: # (### Other changes:) ## [Unreleased] +### Added Features and Improvements 🙌: +- Improved deduplication algorithm in `pplt.legend`. ## [0.13.1] - 2026-05-04 diff --git a/src/prettypyplot/pyplot.py b/src/prettypyplot/pyplot.py index 328c7ac..b29e9e9 100644 --- a/src/prettypyplot/pyplot.py +++ b/src/prettypyplot/pyplot.py @@ -9,6 +9,7 @@ from os import path import numpy as np +from matplotlib import colors as mcolors from matplotlib import legend as mlegend from matplotlib import lines as mlines from matplotlib import patches as mpatches @@ -368,7 +369,13 @@ def _legend_style_frame(leg, *, outside): def _legend_deduplicate(handles, labels): - """Remove duplicate legend entries that share the same label and appearance. + """Deduplicate legend entries by label and visual appearance. + + Entries that share the same label and identical visual appearance are + collapsed to a single entry (keeping the first occurrence). Entries that + share the same label and the same color but have *different* visual + appearances (e.g. a line, a marker, a bar, a patch) are all replaced by a + single filled-square [matplotlib.patches.Patch][] of that color. Parameters ---------- @@ -385,14 +392,34 @@ def _legend_deduplicate(handles, labels): labels : list of str Deduplicated labels. """ - seen = set() - unique_handles, unique_labels = [], [] + # group by (label, color), preserving first-seen insertion order + groups = {} # (label, color_key) -> {'handle_keys': set, 'first_handle': handle} + order = [] # insertion-order list of (label, color_key) + for handle, label in zip(handles, labels): - key = (label, _legend_handle_key(handle)) - if key not in seen: - seen.add(key) - unique_handles.append(handle) - unique_labels.append(label) + color = _legend_handle_color(handle) + color_key = tuple(round(c, 6) for c in color) if color is not None else None + group_key = (label, color_key) + hkey = _legend_handle_key(handle) + + if group_key not in groups: + groups[group_key] = {'handle_keys': {hkey}, 'first_handle': handle} + order.append(group_key) + else: + groups[group_key]['handle_keys'].add(hkey) + + unique_handles, unique_labels = [], [] + for group_key in order: + label, color_key = group_key + entry = groups[group_key] + if len(entry['handle_keys']) > 1 and color_key is not None: + # same label, same color, different appearances → filled square + patch = mpatches.Patch(facecolor=color_key, edgecolor='none') + unique_handles.append(patch) + else: + unique_handles.append(entry['first_handle']) + unique_labels.append(label) + return unique_handles, unique_labels @@ -438,6 +465,36 @@ def _legend_spanning(axs, handles, labels, outside, *args, **kwargs): return fig.legend(handles, labels, *args, **kwargs) +def _legend_handle_color(handle): + """Return the primary color of a legend handle as an RGBA tuple, or None. + + The returned tuple has four float components in [0, 1]. Returns `None` + when no meaningful single color can be extracted. + """ + + def _to_rgba(color): + try: + return tuple(mcolors.to_rgba(color)) + except (ValueError, TypeError): + return None + + if isinstance(handle, mlines.Line2D): + return _to_rgba(handle.get_color()) + if isinstance(handle, mpatches.Patch): + fc = handle.get_facecolor() + return tuple(fc) if len(fc) == 4 else _to_rgba(fc) + if isinstance(handle, PathCollection): + fc = handle.get_facecolor() + if len(fc): + return tuple(fc[0]) + return None + if isinstance(handle, ErrorbarContainer): + return _to_rgba(handle[0].get_color()) + if isinstance(handle, BarContainer): + return tuple(handle.patches[0].get_facecolor()) + return None + + def _legend_handle_key(handle): """Return a hashable visual key for a legend handle.""" if isinstance(handle, mlines.Line2D): diff --git a/tests/test_pyplot.py b/tests/test_pyplot.py index 9646824..14214f0 100644 --- a/tests/test_pyplot.py +++ b/tests/test_pyplot.py @@ -14,7 +14,7 @@ from matplotlib import pyplot as plt import prettypyplot -from prettypyplot.pyplot import _legend_handle_key +from prettypyplot.pyplot import _legend_handle_color, _legend_handle_key @pytest.mark.parametrize( @@ -195,6 +195,37 @@ def test_legend_handle_key_fallback(): assert 'not-a-handle' in key +def test_legend_dedup_same_color_different_patches(): + """Same label + same color but different handle types → single filled square.""" + prettypyplot.use_style() + fig, ax = plt.subplots() + color = 'C0' + ax.plot([0, 1], [0, 1], color=color, label='data') + ax.scatter([0, 1], [0.5, 0.5], color=color, label='data') + ax.bar([0], [1], color=color, label='data') + + leg = prettypyplot.legend(ax=ax) + assert len(leg.get_texts()) == 1 + handle = leg.legend_handles[0] + assert isinstance(handle, mpatches.Patch) + plt.close(fig) + + +def test_legend_handle_color_line2d(): + """_legend_handle_color returns RGBA tuple for Line2D.""" + fig, ax = plt.subplots() + (line,) = ax.plot([0, 1], [0, 1], color='red') + plt.close(fig) + color = _legend_handle_color(line) + assert isinstance(color, tuple) + assert len(color) == 4 + + +def test_legend_handle_color_unknown(): + """_legend_handle_color returns None for unknown handle types.""" + assert _legend_handle_color('not-a-handle') is None + + @pytest.mark.mpl_image_compare(remove_text=True) @pytest.mark.parametrize('outside', ('top', 'bottom', 'right', 'left')) def test_legend_spanning(outside): From 408909a78273df6312130dc625101ace38640db2 Mon Sep 17 00:00:00 2001 From: Daniel Nagel Date: Thu, 7 May 2026 13:35:40 +0200 Subject: [PATCH 2/2] chore: test missing new lines. --- tests/test_pyplot.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_pyplot.py b/tests/test_pyplot.py index 14214f0..485f8c8 100644 --- a/tests/test_pyplot.py +++ b/tests/test_pyplot.py @@ -226,6 +226,31 @@ def test_legend_handle_color_unknown(): assert _legend_handle_color('not-a-handle') is None +def test_legend_handle_color_invalid_color_returns_none(): + """_to_rgba fallback: a Line2D whose color cannot be parsed returns None.""" + from matplotlib import lines as mlines + + line = mlines.Line2D([0, 1], [0, 1]) + line.get_color = lambda: 'definitely-not-a-color' + assert _legend_handle_color(line) is None + + +def test_legend_handle_color_patch(): + """_legend_handle_color returns the facecolor of a Patch.""" + patch = mpatches.Patch(facecolor='red') + color = _legend_handle_color(patch) + assert color == (1.0, 0.0, 0.0, 1.0) + + +def test_legend_handle_color_path_collection_empty(): + """_legend_handle_color returns None when PathCollection has no facecolor.""" + from matplotlib.collections import PathCollection + + pc = PathCollection([], facecolors='none') + pc.get_facecolor = lambda: np.empty((0, 4)) + assert _legend_handle_color(pc) is None + + @pytest.mark.mpl_image_compare(remove_text=True) @pytest.mark.parametrize('outside', ('top', 'bottom', 'right', 'left')) def test_legend_spanning(outside):