Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 65 additions & 8 deletions src/prettypyplot/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------
Expand All @@ -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


Expand Down Expand Up @@ -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):
Expand Down
58 changes: 57 additions & 1 deletion tests/test_pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -195,6 +195,62 @@ 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


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):
Expand Down