Skip to content

Commit 69e0001

Browse files
cvanelterenCopilot
andauthored
Preserve hatches in geometry legend proxies (#612)
* Preserve geometry hatch styles in legend proxies Cartopy geometry artists created with add_geometries() were keeping hatch on the plotted FeatureArtist but dropping it when UltraPlot built the PathPatch legend proxy. This updates the geometry legend handler to copy common patch-style properties more generically, including hatch, while keeping the existing joinstyle fallback. A regression test now covers add_geometries(..., hatch='/', label=...) so semantic geometry legends preserve the plotted hatch pattern. Closes #611 * Generalize geometry legend patch style copying * Document geometry legend style-copy rationale * Black * Document geometry legend proxy limitations * Update ultraplot/legend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 527d02e commit 69e0001

2 files changed

Lines changed: 124 additions & 33 deletions

File tree

ultraplot/legend.py

Lines changed: 103 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ def _fit_path_to_handlebox(
395395
width: float,
396396
height: float,
397397
pad: float = 0.08,
398+
preserve_aspect: bool = True,
398399
) -> mpath.Path:
399400
"""
400401
Normalize an arbitrary path into the legend-handle box.
@@ -411,11 +412,15 @@ def _fit_path_to_handlebox(
411412
py = max(height * pad, 0.0)
412413
span_x = max(width - 2 * px, 1e-12)
413414
span_y = max(height - 2 * py, 1e-12)
414-
scale = min(span_x / dx, span_y / dy)
415415
cx = -xdescent + width * 0.5
416416
cy = -ydescent + height * 0.5
417-
verts[finite, 0] = (verts[finite, 0] - (xmin + xmax) * 0.5) * scale + cx
418-
verts[finite, 1] = (verts[finite, 1] - (ymin + ymax) * 0.5) * scale + cy
417+
if preserve_aspect:
418+
scale_x = scale_y = min(span_x / dx, span_y / dy)
419+
else:
420+
scale_x = span_x / dx
421+
scale_y = span_y / dy
422+
verts[finite, 0] = (verts[finite, 0] - (xmin + xmax) * 0.5) * scale_x + cx
423+
verts[finite, 1] = (verts[finite, 1] - (ymin + ymax) * 0.5) * scale_y + cy
419424
return mpath.Path(
420425
verts, None if path.codes is None else np.array(path.codes, copy=True)
421426
)
@@ -494,6 +499,89 @@ def _patch_joinstyle(value: Any, default: str = _DEFAULT_GEO_JOINSTYLE) -> str:
494499
return default
495500

496501

502+
def _patch_color(
503+
orig_handle: Any,
504+
prop: str,
505+
default: Any = None,
506+
) -> Any:
507+
"""
508+
Resolve a patch color, preferring the artist's original color spec.
509+
510+
Collection-like artists often report post-alpha RGBA arrays from
511+
`get_facecolor()` / `get_edgecolor()`. If we then also copy `alpha`, the
512+
legend proxy ends up visually double-dimmed. Prefer the original color
513+
attributes when available so patch proxies can apply alpha once.
514+
"""
515+
original = getattr(orig_handle, f"_original_{prop}", None)
516+
if original is not None:
517+
value = _first_scalar(original, default=None)
518+
if value is not None:
519+
return value
520+
getter = getattr(orig_handle, f"get_{prop}", None)
521+
if not callable(getter):
522+
return default
523+
try:
524+
value = getter()
525+
except Exception:
526+
return default
527+
return _first_scalar(value, default=default)
528+
529+
530+
_PATCH_STYLE_PROP_SPECS = {
531+
"facecolor": {"default": "none", "transform": None},
532+
"edgecolor": {"default": "none", "transform": None},
533+
"linewidth": {"default": 0.0, "transform": _first_scalar},
534+
"linestyle": {"default": None, "transform": _first_scalar},
535+
"hatch": {"default": None, "transform": None},
536+
"hatch_linewidth": {"default": None, "transform": None},
537+
"fill": {"default": None, "transform": None},
538+
"alpha": {"default": None, "transform": None},
539+
"capstyle": {"default": None, "transform": None},
540+
}
541+
542+
543+
def _copy_patch_style(
544+
legend_handle: mpatches.Patch,
545+
orig_handle: Any,
546+
*,
547+
joinstyle_default: str = _DEFAULT_GEO_JOINSTYLE,
548+
) -> None:
549+
"""
550+
Copy common patch-style properties from source artist to legend proxy.
551+
552+
Matplotlib does not provide a reliable generic style-transfer API for
553+
cross-family artists here. In particular, `Artist.update_from()` is not
554+
safe for `Collection -> Patch` copies like `FeatureArtist -> PathPatch`,
555+
and `properties()` still leaves us to normalize collection-valued fields.
556+
So this helper intentionally copies the shared patch-style surface only.
557+
"""
558+
for prop, spec in _PATCH_STYLE_PROP_SPECS.items():
559+
setter = getattr(legend_handle, f"set_{prop}", None)
560+
if not callable(setter):
561+
continue
562+
default = spec["default"]
563+
if prop in ("facecolor", "edgecolor"):
564+
value = _patch_color(orig_handle, prop, default=default)
565+
else:
566+
getter = getattr(orig_handle, f"get_{prop}", None)
567+
if not callable(getter):
568+
continue
569+
try:
570+
value = getter()
571+
except Exception:
572+
continue
573+
transform = spec["transform"]
574+
if transform is not None:
575+
value = transform(value, default=default)
576+
elif value is None:
577+
value = default
578+
if value is not None:
579+
setter(value)
580+
legend_handle.set_joinstyle(
581+
_patch_joinstyle(orig_handle, default=joinstyle_default)
582+
)
583+
584+
497585
def _feature_legend_patch(
498586
legend,
499587
orig_handle,
@@ -515,6 +603,7 @@ def _feature_legend_patch(
515603
ydescent=ydescent,
516604
width=width,
517605
height=height,
606+
preserve_aspect=False,
518607
)
519608
return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE)
520609

@@ -544,6 +633,7 @@ def _shapely_geometry_patch(
544633
ydescent=ydescent,
545634
width=width,
546635
height=height,
636+
preserve_aspect=False,
547637
)
548638
return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE)
549639

@@ -566,6 +656,7 @@ def _geometry_entry_patch(
566656
ydescent=ydescent,
567657
width=width,
568658
height=height,
659+
preserve_aspect=True,
569660
)
570661
return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE)
571662

@@ -579,36 +670,7 @@ def __init__(self):
579670
super().__init__(patch_func=_feature_legend_patch)
580671

581672
def update_prop(self, legend_handle, orig_handle, legend):
582-
facecolor = _first_scalar(
583-
(
584-
orig_handle.get_facecolor()
585-
if hasattr(orig_handle, "get_facecolor")
586-
else None
587-
),
588-
default="none",
589-
)
590-
edgecolor = _first_scalar(
591-
(
592-
orig_handle.get_edgecolor()
593-
if hasattr(orig_handle, "get_edgecolor")
594-
else None
595-
),
596-
default="none",
597-
)
598-
linewidth = _first_scalar(
599-
(
600-
orig_handle.get_linewidth()
601-
if hasattr(orig_handle, "get_linewidth")
602-
else None
603-
),
604-
default=0.0,
605-
)
606-
legend_handle.set_facecolor(facecolor)
607-
legend_handle.set_edgecolor(edgecolor)
608-
legend_handle.set_linewidth(linewidth)
609-
legend_handle.set_joinstyle(_patch_joinstyle(orig_handle))
610-
if hasattr(orig_handle, "get_alpha"):
611-
legend_handle.set_alpha(orig_handle.get_alpha())
673+
_copy_patch_style(legend_handle, orig_handle)
612674
legend._set_artist_props(legend_handle)
613675
legend_handle.set_clip_box(None)
614676
legend_handle.set_clip_path(None)
@@ -1638,6 +1700,14 @@ def geolegend(
16381700
):
16391701
"""
16401702
Build geometry legend entries and optionally draw a legend.
1703+
1704+
Notes
1705+
-----
1706+
Geometry legend entries use normalized patch proxies inside the legend
1707+
handle box rather than reusing the original map artist directly. This
1708+
preserves the general geometry shape and copied patch styling, but very
1709+
small or high-aspect-ratio handles can still make hatches difficult to
1710+
read at legend scale.
16411711
"""
16421712
facecolor = _not_none(facecolor, rc["legend.geo.facecolor"])
16431713
edgecolor = _not_none(edgecolor, rc["legend.geo.edgecolor"])

ultraplot/tests/test_legend.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,27 @@ def test_geo_axes_add_geometries_auto_legend():
772772
uplt.close(fig)
773773

774774

775+
def test_geo_axes_add_geometries_auto_legend_preserves_hatch():
776+
ccrs = pytest.importorskip("cartopy.crs")
777+
sgeom = pytest.importorskip("shapely.geometry")
778+
779+
fig, ax = uplt.subplots(proj="cyl")
780+
ax.add_geometries(
781+
[sgeom.box(-20, -10, 20, 10)],
782+
ccrs.PlateCarree(),
783+
facecolor="gray5",
784+
edgecolor="red7",
785+
alpha=0.2,
786+
hatch="/",
787+
label="Region",
788+
)
789+
leg = ax.legend(loc="best")
790+
assert len(leg.legend_handles) == 1
791+
assert isinstance(leg.legend_handles[0], mpatches.PathPatch)
792+
assert leg.legend_handles[0].get_hatch() == "/"
793+
uplt.close(fig)
794+
795+
775796
def test_geo_legend_defaults_to_bevel_joinstyle():
776797
fig, ax = uplt.subplots()
777798
leg = ax.geolegend([("shape", "triangle")], loc="best")

0 commit comments

Comments
 (0)