From 0f13209076c905800159b328d27f69d4ecd9d7ef Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 17 Nov 2025 18:52:10 +0530 Subject: [PATCH 01/18] Add support for PatchCollection legends and update documentation --- .../next_whats_new/patchcollection_legend.rst | 23 +++ lib/matplotlib/legend.py | 3 +- lib/matplotlib/legend_handler.py | 17 ++ lib/matplotlib/tests/test_legend.py | 147 ++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 doc/release/next_whats_new/patchcollection_legend.rst diff --git a/doc/release/next_whats_new/patchcollection_legend.rst b/doc/release/next_whats_new/patchcollection_legend.rst new file mode 100644 index 000000000000..2b77723dcb16 --- /dev/null +++ b/doc/release/next_whats_new/patchcollection_legend.rst @@ -0,0 +1,23 @@ +``PatchCollection`` legends now supported +------------------------------------------ +`.PatchCollection` instances now properly display in legends when given a label. +Previously, labels on `~.PatchCollection` objects were ignored by the legend +system, requiring users to create manual legend entries. + +The legend entry displays a rectangle matching the visual properties (colors, +line styles, line widths) of the first patch in the collection. + +.. code-block:: python + + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.collections import PatchCollection + + fig, ax = plt.subplots() + patches = [mpatches.Circle((0, 0), 0.1), mpatches.Rectangle((0.5, 0.5), 0.2, 0.3)] + pc = PatchCollection(patches, facecolor='blue', edgecolor='black', label='My patches') + ax.add_collection(pc) + ax.legend() # Now displays the label "My patches" + plt.show() + +This fix resolves :ghissue:`23998`. diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 119a27181c80..f9b88ea9712e 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -37,7 +37,7 @@ from matplotlib.patches import (Patch, Rectangle, Shadow, FancyBboxPatch, StepPatch) from matplotlib.collections import ( - Collection, CircleCollection, LineCollection, PathCollection, + Collection, CircleCollection, LineCollection, PatchCollection, PathCollection, PolyCollection, RegularPolyCollection) from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox @@ -787,6 +787,7 @@ def draw(self, renderer): BarContainer: legend_handler.HandlerPatch( update_func=legend_handler.update_from_first_child), tuple: legend_handler.HandlerTuple(), + PatchCollection: legend_handler.HandlerPatchCollection(), PathCollection: legend_handler.HandlerPathCollection(), PolyCollection: legend_handler.HandlerPolyCollection() } diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 65a78891b17f..786ab495f59e 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -809,3 +809,20 @@ def create_artists(self, legend, orig_handle, self.update_prop(p, orig_handle, legend) p.set_transform(trans) return [p] + + +class HandlerPatchCollection(HandlerPolyCollection): + """ + Handler for `.PatchCollection` instances. + + This handler creates a rectangular legend entry that matches the visual + properties (colors, line styles, etc.) of the first patch in the collection. + + See Also + -------- + matplotlib.collections.PatchCollection : The collection class this handles. + """ + # PatchCollection and PolyCollection have the same API for accessing + # colors, line properties, etc., so we can reuse HandlerPolyCollection + # implementation directly via inheritance. + pass diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 61fae63a298e..c17aa1973c4c 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1667,3 +1667,150 @@ def test_boxplot_legend_labels(): bp4 = axs[3].boxplot(data, label='box A') assert bp4['medians'][0].get_label() == 'box A' assert all(x.get_label().startswith("_") for x in bp4['medians'][1:]) + + +def test_patchcollection_legend(): + # Test that PatchCollection labels show up in legend (issue #23998) + fig, ax = plt.subplots() + + # Create some patches + p1 = mpatches.Polygon([[0, 0], [100, 100], [200, 0]]) + p2 = mpatches.Polygon([[400, 0], [500, 100], [600, 0]]) + + # Create a PatchCollection with a label + pc = mcollections.PatchCollection([p1, p2], label="patch collection", + facecolor='blue', edgecolor='black') + ax.add_collection(pc, autolim=True) + ax.autoscale_view() + + # Create the legend + leg = ax.legend() + + # Check that the legend contains our label + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "patch collection" + + # Check that there's a legend handle + handles = leg.legend_handles + assert len(handles) == 1 + assert isinstance(handles[0], mpatches.Rectangle) + + +def test_patchcollection_legend_properties(): + # Test that PatchCollection legend preserves visual properties + fig, ax = plt.subplots() + + # Create patches with specific properties + p1 = mpatches.Circle((0, 0), 1) + p2 = mpatches.Circle((2, 0), 1) + + # Create a PatchCollection with specific visual properties + pc = mcollections.PatchCollection( + [p1, p2], + label="styled patches", + facecolor='red', + edgecolor='blue', + linewidths=3, + linestyle='--' + ) + ax.add_collection(pc) + ax.autoscale_view() + + # Create the legend + leg = ax.legend() + legend_patch = leg.legend_handles[0] + + # Verify that visual properties are preserved + assert_allclose(legend_patch.get_facecolor()[:3], + pc.get_facecolor()[0][:3], rtol=1e-5) + assert_allclose(legend_patch.get_edgecolor()[:3], + pc.get_edgecolor()[0][:3], rtol=1e-5) + assert legend_patch.get_linewidth() == pc.get_linewidths()[0] + assert legend_patch.get_linestyle() == pc.get_linestyles()[0] + + +def test_patchcollection_legend_match_original(): + # Test PatchCollection legend with match_original=True + fig, ax = plt.subplots() + + # Create patches with individual colors + p1 = mpatches.Rectangle((0, 0), 1, 1, facecolor='red', edgecolor='black') + p2 = mpatches.Rectangle((2, 0), 1, 1, facecolor='green', edgecolor='blue') + + # Create a PatchCollection with match_original=True + # This should use the first patch's colors + pc = mcollections.PatchCollection([p1, p2], match_original=True, + label="original colors") + ax.add_collection(pc) + ax.autoscale_view() + + # Create the legend + leg = ax.legend() + legend_patch = leg.legend_handles[0] + + # The legend should show the first patch's colors (red face, black edge) + # Note: colors are normalized to RGBA tuples + assert_allclose(legend_patch.get_facecolor()[:3], + pc.get_facecolor()[0][:3], rtol=1e-5) + assert_allclose(legend_patch.get_edgecolor()[:3], + pc.get_edgecolor()[0][:3], rtol=1e-5) + + +def test_patchcollection_legend_empty(): + # Test that empty PatchCollection doesn't crash + fig, ax = plt.subplots() + + # Create an empty PatchCollection + pc = mcollections.PatchCollection([], label="empty collection") + ax.add_collection(pc) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + + # This should not crash + leg = ax.legend() + + # Check that the label still appears + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "empty collection" + + # The legend handle should exist (with default/transparent colors) + assert len(leg.legend_handles) == 1 + assert isinstance(leg.legend_handles[0], mpatches.Rectangle) + + +@image_comparison(['patchcollection_legend.png'], remove_text=True) +def test_patchcollection_legend_rendering(): + # Visual regression test for PatchCollection legend (issue #23998) + fig, ax = plt.subplots() + + # Create various patch shapes + patches = [ + mpatches.Circle((0.2, 0.5), 0.15), + mpatches.Rectangle((0.5, 0.3), 0.2, 0.4), + mpatches.Polygon([[0.8, 0.2], [1.0, 0.7], [0.9, 0.3]]), + ] + + # Create three PatchCollections with different styles + pc1 = mcollections.PatchCollection( + patches, facecolor='red', edgecolor='darkred', + linewidths=2, alpha=0.7, label='Red patches' + ) + + pc2 = mcollections.PatchCollection( + [mpatches.Rectangle((0, 0), 0.1, 0.1) for _ in range(3)], + facecolor='blue', edgecolor='navy', + linewidths=1, linestyle='--', label='Blue dashed' + ) + + pc3 = mcollections.PatchCollection( + [mpatches.Circle((0, 0), 0.1) for _ in range(2)], + facecolor='green', edgecolor='none', + label='Green no edge' + ) + + for pc in [pc1, pc2, pc3]: + ax.add_collection(pc) + + ax.set_xlim(0, 1.1) + ax.set_ylim(0, 0.8) + ax.legend(loc='upper left') From 3f013f62e111d499ccc8cfd0a85b4e3df60b27eb Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 17 Nov 2025 13:45:17 +0000 Subject: [PATCH 02/18] Add type stub for HandlerPatchCollection --- lib/matplotlib/legend_handler.pyi | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/legend_handler.pyi b/lib/matplotlib/legend_handler.pyi index db028a136a48..429986fce6a4 100644 --- a/lib/matplotlib/legend_handler.pyi +++ b/lib/matplotlib/legend_handler.pyi @@ -292,3 +292,5 @@ class HandlerPolyCollection(HandlerBase): fontsize: float, trans: Transform, ) -> Sequence[Artist]: ... + +class HandlerPatchCollection(HandlerPolyCollection): ... From 567b478c3af62b3d8277e4274f87909f226a0fe5 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 17 Nov 2025 14:10:18 +0000 Subject: [PATCH 03/18] Remove visual regression test (baseline image needed) The @image_comparison test requires a baseline image that doesn't exist yet. Removing it for now - we still have 4 comprehensive functional tests that verify the functionality works correctly. --- lib/matplotlib/tests/test_legend.py | 38 ----------------------------- 1 file changed, 38 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index c17aa1973c4c..bfe13bdd43e2 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1776,41 +1776,3 @@ def test_patchcollection_legend_empty(): # The legend handle should exist (with default/transparent colors) assert len(leg.legend_handles) == 1 assert isinstance(leg.legend_handles[0], mpatches.Rectangle) - - -@image_comparison(['patchcollection_legend.png'], remove_text=True) -def test_patchcollection_legend_rendering(): - # Visual regression test for PatchCollection legend (issue #23998) - fig, ax = plt.subplots() - - # Create various patch shapes - patches = [ - mpatches.Circle((0.2, 0.5), 0.15), - mpatches.Rectangle((0.5, 0.3), 0.2, 0.4), - mpatches.Polygon([[0.8, 0.2], [1.0, 0.7], [0.9, 0.3]]), - ] - - # Create three PatchCollections with different styles - pc1 = mcollections.PatchCollection( - patches, facecolor='red', edgecolor='darkred', - linewidths=2, alpha=0.7, label='Red patches' - ) - - pc2 = mcollections.PatchCollection( - [mpatches.Rectangle((0, 0), 0.1, 0.1) for _ in range(3)], - facecolor='blue', edgecolor='navy', - linewidths=1, linestyle='--', label='Blue dashed' - ) - - pc3 = mcollections.PatchCollection( - [mpatches.Circle((0, 0), 0.1) for _ in range(2)], - facecolor='green', edgecolor='none', - label='Green no edge' - ) - - for pc in [pc1, pc2, pc3]: - ax.add_collection(pc) - - ax.set_xlim(0, 1.1) - ax.set_ylim(0, 0.8) - ax.legend(loc='upper left') From 8fc57eb516459be9be10276c971b66f82c6141c6 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 17 Nov 2025 14:47:42 +0000 Subject: [PATCH 04/18] Trigger CI re-run From 478de4522aef189337353fa8e8debd4bc06c576f Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 17 Nov 2025 16:35:18 +0000 Subject: [PATCH 05/18] Add explicit figure cleanup in PatchCollection legend tests Close figures explicitly at the end of each test to prevent any potential state leakage that could affect other tests in parallel execution environments. --- lib/matplotlib/tests/test_legend.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index bfe13bdd43e2..7185acc45f20 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1695,6 +1695,8 @@ def test_patchcollection_legend(): assert len(handles) == 1 assert isinstance(handles[0], mpatches.Rectangle) + plt.close(fig) + def test_patchcollection_legend_properties(): # Test that PatchCollection legend preserves visual properties @@ -1728,6 +1730,8 @@ def test_patchcollection_legend_properties(): assert legend_patch.get_linewidth() == pc.get_linewidths()[0] assert legend_patch.get_linestyle() == pc.get_linestyles()[0] + plt.close(fig) + def test_patchcollection_legend_match_original(): # Test PatchCollection legend with match_original=True @@ -1755,6 +1759,8 @@ def test_patchcollection_legend_match_original(): assert_allclose(legend_patch.get_edgecolor()[:3], pc.get_edgecolor()[0][:3], rtol=1e-5) + plt.close(fig) + def test_patchcollection_legend_empty(): # Test that empty PatchCollection doesn't crash @@ -1776,3 +1782,5 @@ def test_patchcollection_legend_empty(): # The legend handle should exist (with default/transparent colors) assert len(leg.legend_handles) == 1 assert isinstance(leg.legend_handles[0], mpatches.Rectangle) + + plt.close(fig) From 8536bfca7998ea3a47af99673b5cd159be6800bd Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:12:54 +0530 Subject: [PATCH 06/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 7185acc45f20..fb22e763f4e5 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1695,8 +1695,6 @@ def test_patchcollection_legend(): assert len(handles) == 1 assert isinstance(handles[0], mpatches.Rectangle) - plt.close(fig) - def test_patchcollection_legend_properties(): # Test that PatchCollection legend preserves visual properties From 71c9a866e92a2856f1ae1fbb27763a2182c729d9 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:03 +0530 Subject: [PATCH 07/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index fb22e763f4e5..70abfc644de3 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1728,8 +1728,6 @@ def test_patchcollection_legend_properties(): assert legend_patch.get_linewidth() == pc.get_linewidths()[0] assert legend_patch.get_linestyle() == pc.get_linestyles()[0] - plt.close(fig) - def test_patchcollection_legend_match_original(): # Test PatchCollection legend with match_original=True From 618d426cf387108a11eb9319ff19181e6076b935 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:13 +0530 Subject: [PATCH 08/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 70abfc644de3..4599ae468a1f 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1755,8 +1755,6 @@ def test_patchcollection_legend_match_original(): assert_allclose(legend_patch.get_edgecolor()[:3], pc.get_edgecolor()[0][:3], rtol=1e-5) - plt.close(fig) - def test_patchcollection_legend_empty(): # Test that empty PatchCollection doesn't crash From 122a7d638145bb49ca63e6451087d172dbc811b9 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:21 +0530 Subject: [PATCH 09/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 4599ae468a1f..bfe13bdd43e2 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1776,5 +1776,3 @@ def test_patchcollection_legend_empty(): # The legend handle should exist (with default/transparent colors) assert len(leg.legend_handles) == 1 assert isinstance(leg.legend_handles[0], mpatches.Rectangle) - - plt.close(fig) From 83adf5ce86aa1af65780c4fa23513a3881222a66 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:31 +0530 Subject: [PATCH 10/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index bfe13bdd43e2..a60d535cec9b 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1700,18 +1700,13 @@ def test_patchcollection_legend_properties(): # Test that PatchCollection legend preserves visual properties fig, ax = plt.subplots() - # Create patches with specific properties - p1 = mpatches.Circle((0, 0), 1) - p2 = mpatches.Circle((2, 0), 1) - - # Create a PatchCollection with specific visual properties pc = mcollections.PatchCollection( - [p1, p2], + [mpatches.Circle((0, 0), 1), mpatches.Circle((2, 0), 1)], label="styled patches", facecolor='red', edgecolor='blue', linewidths=3, - linestyle='--' + linestyle='--', ) ax.add_collection(pc) ax.autoscale_view() From 477bf9705a3e6318a6edb164665c516c4a66a010 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:48 +0530 Subject: [PATCH 11/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index a60d535cec9b..cbb2aa4f7af9 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1713,9 +1713,9 @@ def test_patchcollection_legend_properties(): # Create the legend leg = ax.legend() - legend_patch = leg.legend_handles[0] # Verify that visual properties are preserved + legend_patch = leg.legend_handles[0] assert_allclose(legend_patch.get_facecolor()[:3], pc.get_facecolor()[0][:3], rtol=1e-5) assert_allclose(legend_patch.get_edgecolor()[:3], From 1a1b20aa096d450dd48f2553866959ea5e661169 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 12:13:57 +0530 Subject: [PATCH 12/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index cbb2aa4f7af9..27439dc53068 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1711,7 +1711,6 @@ def test_patchcollection_legend_properties(): ax.add_collection(pc) ax.autoscale_view() - # Create the legend leg = ax.legend() # Verify that visual properties are preserved From bd2f926ce0ada6367eb855ea52678cac23cdb64b Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 13:31:29 +0530 Subject: [PATCH 13/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 27439dc53068..5ed907a3ed30 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1683,7 +1683,6 @@ def test_patchcollection_legend(): ax.add_collection(pc, autolim=True) ax.autoscale_view() - # Create the legend leg = ax.legend() # Check that the legend contains our label From 33242cacc51c0d53e3a062c642fb1059b1290238 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 13:31:50 +0530 Subject: [PATCH 14/18] Update lib/matplotlib/tests/test_legend.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/tests/test_legend.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 5ed907a3ed30..cb26f711e368 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1673,13 +1673,13 @@ def test_patchcollection_legend(): # Test that PatchCollection labels show up in legend (issue #23998) fig, ax = plt.subplots() - # Create some patches - p1 = mpatches.Polygon([[0, 0], [100, 100], [200, 0]]) - p2 = mpatches.Polygon([[400, 0], [500, 100], [600, 0]]) - - # Create a PatchCollection with a label - pc = mcollections.PatchCollection([p1, p2], label="patch collection", - facecolor='blue', edgecolor='black') + pc = mcollections.PatchCollection( + [ + mpatches.Polygon([[0, 0], [100, 100], [200, 0]]), + mpatches.Polygon([[400, 0], [500, 100], [600, 0]]), + ], + label="patch collection", facecolor='blue', edgecolor='black' + ) ax.add_collection(pc, autolim=True) ax.autoscale_view() From b669f4d9409a64e2035d0b18ff04db1547b68fda Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 08:07:20 +0000 Subject: [PATCH 15/18] Remove redundant test_patchcollection_legend_match_original test As noted by @timhoffm, this test doesn't add value because: - match_original affects how colors are applied to patches in the collection - The legend handler always takes properties from the first patch - Whether match_original is True or False, the legend gets the same result - This is already covered by test_patchcollection_legend_properties --- lib/matplotlib/tests/test_legend.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index cb26f711e368..6460312cf544 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1722,33 +1722,6 @@ def test_patchcollection_legend_properties(): assert legend_patch.get_linestyle() == pc.get_linestyles()[0] -def test_patchcollection_legend_match_original(): - # Test PatchCollection legend with match_original=True - fig, ax = plt.subplots() - - # Create patches with individual colors - p1 = mpatches.Rectangle((0, 0), 1, 1, facecolor='red', edgecolor='black') - p2 = mpatches.Rectangle((2, 0), 1, 1, facecolor='green', edgecolor='blue') - - # Create a PatchCollection with match_original=True - # This should use the first patch's colors - pc = mcollections.PatchCollection([p1, p2], match_original=True, - label="original colors") - ax.add_collection(pc) - ax.autoscale_view() - - # Create the legend - leg = ax.legend() - legend_patch = leg.legend_handles[0] - - # The legend should show the first patch's colors (red face, black edge) - # Note: colors are normalized to RGBA tuples - assert_allclose(legend_patch.get_facecolor()[:3], - pc.get_facecolor()[0][:3], rtol=1e-5) - assert_allclose(legend_patch.get_edgecolor()[:3], - pc.get_edgecolor()[0][:3], rtol=1e-5) - - def test_patchcollection_legend_empty(): # Test that empty PatchCollection doesn't crash fig, ax = plt.subplots() From 92aaecaa3ffc925d52a76a424be8a5fbec0ff791 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 18 Nov 2025 08:09:25 +0000 Subject: [PATCH 16/18] Test behavior instead of implementation details As suggested by @timhoffm: - Remove isinstance(Rectangle) checks - this is an implementation detail - In test_patchcollection_legend: Check colors match instead - In test_patchcollection_legend_empty: Just verify legend handle exists This focuses tests on actual behavior (correct colors) rather than internal implementation choices (what patch type is used). --- lib/matplotlib/tests/test_legend.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 6460312cf544..0ed607c1507f 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1689,10 +1689,13 @@ def test_patchcollection_legend(): assert len(leg.get_texts()) == 1 assert leg.get_texts()[0].get_text() == "patch collection" - # Check that there's a legend handle - handles = leg.legend_handles - assert len(handles) == 1 - assert isinstance(handles[0], mpatches.Rectangle) + # Check that the legend handle exists and has correct colors + assert len(leg.legend_handles) == 1 + legend_patch = leg.legend_handles[0] + assert_allclose(legend_patch.get_facecolor()[:3], + pc.get_facecolor()[0][:3], rtol=1e-5) + assert_allclose(legend_patch.get_edgecolor()[:3], + pc.get_edgecolor()[0][:3], rtol=1e-5) def test_patchcollection_legend_properties(): @@ -1739,6 +1742,5 @@ def test_patchcollection_legend_empty(): assert len(leg.get_texts()) == 1 assert leg.get_texts()[0].get_text() == "empty collection" - # The legend handle should exist (with default/transparent colors) + # The legend handle should exist assert len(leg.legend_handles) == 1 - assert isinstance(leg.legend_handles[0], mpatches.Rectangle) From 555faa660d1ee864f0202aa9dda9960be589059b Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 20 Nov 2025 08:46:43 +0000 Subject: [PATCH 17/18] Address story645's review: simplify implementation and improve tests - Remove HandlerPatchCollection class - use HandlerPolyCollection directly Since PatchCollection and PolyCollection have identical APIs, we can register PatchCollection to use the existing handler without creating a new class. This follows YAGNI principle. - Check all 4 color components (RGBA) instead of just RGB The alpha channel should also be verified for completeness. - Remove unnecessary axis limits from empty collection test Since we're only testing object properties, not visual rendering, the axis limits aren't needed. Addresses feedback from @story645 --- lib/matplotlib/legend.py | 2 +- lib/matplotlib/legend_handler.py | 17 ----------------- lib/matplotlib/legend_handler.pyi | 2 -- lib/matplotlib/tests/test_legend.py | 18 ++++++++---------- 4 files changed, 9 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index f9b88ea9712e..8564c18c5118 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -787,7 +787,7 @@ def draw(self, renderer): BarContainer: legend_handler.HandlerPatch( update_func=legend_handler.update_from_first_child), tuple: legend_handler.HandlerTuple(), - PatchCollection: legend_handler.HandlerPatchCollection(), + PatchCollection: legend_handler.HandlerPolyCollection(), PathCollection: legend_handler.HandlerPathCollection(), PolyCollection: legend_handler.HandlerPolyCollection() } diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 786ab495f59e..65a78891b17f 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -809,20 +809,3 @@ def create_artists(self, legend, orig_handle, self.update_prop(p, orig_handle, legend) p.set_transform(trans) return [p] - - -class HandlerPatchCollection(HandlerPolyCollection): - """ - Handler for `.PatchCollection` instances. - - This handler creates a rectangular legend entry that matches the visual - properties (colors, line styles, etc.) of the first patch in the collection. - - See Also - -------- - matplotlib.collections.PatchCollection : The collection class this handles. - """ - # PatchCollection and PolyCollection have the same API for accessing - # colors, line properties, etc., so we can reuse HandlerPolyCollection - # implementation directly via inheritance. - pass diff --git a/lib/matplotlib/legend_handler.pyi b/lib/matplotlib/legend_handler.pyi index 429986fce6a4..db028a136a48 100644 --- a/lib/matplotlib/legend_handler.pyi +++ b/lib/matplotlib/legend_handler.pyi @@ -292,5 +292,3 @@ class HandlerPolyCollection(HandlerBase): fontsize: float, trans: Transform, ) -> Sequence[Artist]: ... - -class HandlerPatchCollection(HandlerPolyCollection): ... diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 0ed607c1507f..b8e67c09ab46 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1692,10 +1692,10 @@ def test_patchcollection_legend(): # Check that the legend handle exists and has correct colors assert len(leg.legend_handles) == 1 legend_patch = leg.legend_handles[0] - assert_allclose(legend_patch.get_facecolor()[:3], - pc.get_facecolor()[0][:3], rtol=1e-5) - assert_allclose(legend_patch.get_edgecolor()[:3], - pc.get_edgecolor()[0][:3], rtol=1e-5) + assert_allclose(legend_patch.get_facecolor(), + pc.get_facecolor()[0], rtol=1e-5) + assert_allclose(legend_patch.get_edgecolor(), + pc.get_edgecolor()[0], rtol=1e-5) def test_patchcollection_legend_properties(): @@ -1717,10 +1717,10 @@ def test_patchcollection_legend_properties(): # Verify that visual properties are preserved legend_patch = leg.legend_handles[0] - assert_allclose(legend_patch.get_facecolor()[:3], - pc.get_facecolor()[0][:3], rtol=1e-5) - assert_allclose(legend_patch.get_edgecolor()[:3], - pc.get_edgecolor()[0][:3], rtol=1e-5) + assert_allclose(legend_patch.get_facecolor(), + pc.get_facecolor()[0], rtol=1e-5) + assert_allclose(legend_patch.get_edgecolor(), + pc.get_edgecolor()[0], rtol=1e-5) assert legend_patch.get_linewidth() == pc.get_linewidths()[0] assert legend_patch.get_linestyle() == pc.get_linestyles()[0] @@ -1732,8 +1732,6 @@ def test_patchcollection_legend_empty(): # Create an empty PatchCollection pc = mcollections.PatchCollection([], label="empty collection") ax.add_collection(pc) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) # This should not crash leg = ax.legend() From 84a911ed2de902704bfbaef6434908b534d937da Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Mon, 24 Nov 2025 19:16:53 +0530 Subject: [PATCH 18/18] Update documentation and tests for PatchCollection legend support --- .../next_whats_new/patchcollection_legend.rst | 7 ++- lib/matplotlib/tests/test_legend.py | 49 +++++-------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/doc/release/next_whats_new/patchcollection_legend.rst b/doc/release/next_whats_new/patchcollection_legend.rst index 2b77723dcb16..58574e9e6757 100644 --- a/doc/release/next_whats_new/patchcollection_legend.rst +++ b/doc/release/next_whats_new/patchcollection_legend.rst @@ -4,10 +4,9 @@ Previously, labels on `~.PatchCollection` objects were ignored by the legend system, requiring users to create manual legend entries. -The legend entry displays a rectangle matching the visual properties (colors, -line styles, line widths) of the first patch in the collection. - -.. code-block:: python +.. plot:: + :include-source: true + :alt: The legend entry displays a rectangle matching the visual properties (colors, line styles, line widths) of the first patch in the collection. import matplotlib.pyplot as plt import matplotlib.patches as mpatches diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index b8e67c09ab46..a99192c4d571 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1670,41 +1670,13 @@ def test_boxplot_legend_labels(): def test_patchcollection_legend(): - # Test that PatchCollection labels show up in legend (issue #23998) - fig, ax = plt.subplots() - - pc = mcollections.PatchCollection( - [ - mpatches.Polygon([[0, 0], [100, 100], [200, 0]]), - mpatches.Polygon([[400, 0], [500, 100], [600, 0]]), - ], - label="patch collection", facecolor='blue', edgecolor='black' - ) - ax.add_collection(pc, autolim=True) - ax.autoscale_view() - - leg = ax.legend() - - # Check that the legend contains our label - assert len(leg.get_texts()) == 1 - assert leg.get_texts()[0].get_text() == "patch collection" - - # Check that the legend handle exists and has correct colors - assert len(leg.legend_handles) == 1 - legend_patch = leg.legend_handles[0] - assert_allclose(legend_patch.get_facecolor(), - pc.get_facecolor()[0], rtol=1e-5) - assert_allclose(legend_patch.get_edgecolor(), - pc.get_edgecolor()[0], rtol=1e-5) - - -def test_patchcollection_legend_properties(): - # Test that PatchCollection legend preserves visual properties + # Test that PatchCollection labels show up in legend and preserve visual + # properties (issue #23998) fig, ax = plt.subplots() pc = mcollections.PatchCollection( [mpatches.Circle((0, 0), 1), mpatches.Circle((2, 0), 1)], - label="styled patches", + label="patch collection", facecolor='red', edgecolor='blue', linewidths=3, @@ -1715,12 +1687,17 @@ def test_patchcollection_legend_properties(): leg = ax.legend() - # Verify that visual properties are preserved + # Check that the legend contains our label + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "patch collection" + + # Check that the legend handle exists and has correct visual properties + assert len(leg.legend_handles) == 1 legend_patch = leg.legend_handles[0] - assert_allclose(legend_patch.get_facecolor(), - pc.get_facecolor()[0], rtol=1e-5) - assert_allclose(legend_patch.get_edgecolor(), - pc.get_edgecolor()[0], rtol=1e-5) + assert mpl.colors.same_color(legend_patch.get_facecolor(), + pc.get_facecolor()[0]) + assert mpl.colors.same_color(legend_patch.get_edgecolor(), + pc.get_edgecolor()[0]) assert legend_patch.get_linewidth() == pc.get_linewidths()[0] assert legend_patch.get_linestyle() == pc.get_linestyles()[0]