From 9a9364398070d16c021f10f7b8a34f6525050642 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 31 Mar 2026 09:25:59 +1000 Subject: [PATCH 1/2] Fix choropleth horizontal line artifacts on projected maps Use Cartopy's project_geometry() instead of transform_points() for choropleth polygon projection. transform_points() projects vertices independently without geometric awareness, so polygons crossing the antimeridian (e.g. Russia) produce path segments that jump across the entire map, rendering as visible horizontal lines. project_geometry() properly splits and clips polygons at projection boundaries before converting to matplotlib paths, eliminating the artifacts on all projections (Robinson, Mercator, Mollweide, etc.). --- ultraplot/axes/geo.py | 16 +++++++ ultraplot/tests/test_geographic.py | 72 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index d83a4c951..868f1b008 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -3725,6 +3725,22 @@ def _choropleth_geometry_path( """ Convert a polygon geometry to a projected matplotlib path. """ + if ax._name == "cartopy": + src = transform + if src is None: + if ccrs is None: + raise RuntimeError("choropleth() requires cartopy for cartopy GeoAxes.") + src = ccrs.PlateCarree() + projected_geom = ax.projection.project_geometry(geometry, src) + paths = [] + for ring in _choropleth_iter_rings(projected_geom): + path = _choropleth_close_path(np.asarray(ring, dtype=float)) + if path is not None: + paths.append(path) + if not paths: + return None + return mpath.Path.make_compound_path(*paths) + paths = [] for ring in _choropleth_iter_rings(geometry): projected = _choropleth_project_vertices(ax, ring, transform=transform) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 802670d9e..5e0a50558 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -1116,6 +1116,78 @@ def test_choropleth_country_mapping_with_explicit_values_raises(): uplt.close(fig) +def test_choropleth_antimeridian_no_horizontal_artifacts(): + """ + Polygons crossing the antimeridian (e.g. Russia) must be split by + project_geometry so that no path vertex jumps across the map. + """ + sgeom = pytest.importorskip("shapely.geometry") + ccrs = pytest.importorskip("cartopy.crs") + + # A box that crosses the antimeridian: 170E to 190E (= 170W) + box = sgeom.box(170, 50, 190, 70) + fig, ax = uplt.subplots(proj="robin") + geo = ax[0] + coll = geo.choropleth([box], [1.0]) + fig.canvas.draw() + + # After project_geometry splits the box, the compound path should + # have multiple sub-paths (MOVETO codes) rather than one continuous ring + paths = coll.get_paths() + assert len(paths) >= 1 + codes = paths[0].codes + moveto_count = (codes == 1).sum() # Path.MOVETO == 1 + assert moveto_count >= 2, ( + "Antimeridian-crossing polygon should be split into multiple sub-paths" + ) + uplt.close(fig) + + +def test_choropleth_project_geometry_non_cylindrical(): + """ + Choropleth on non-cylindrical projections (Robinson, Mollweide, etc.) + should render without errors for geometries that span wide longitudes. + """ + sgeom = pytest.importorskip("shapely.geometry") + pytest.importorskip("cartopy.crs") + + # Wide-spanning box (like Russia or Canada) + box = sgeom.box(-170, 40, 170, 75) + for proj in ("robin", "moll", "merc"): + fig, ax = uplt.subplots(proj=proj) + coll = ax[0].choropleth([box], [42.0]) + fig.canvas.draw() + + paths = coll.get_paths() + assert len(paths) >= 1 + # Verify no inf/nan in projected vertices + for path in paths: + verts = path.vertices + assert np.all(np.isfinite(verts)), ( + f"Projected path has non-finite vertices on {proj!r} projection" + ) + uplt.close(fig) + + +def test_choropleth_country_antimeridian_renders(): + """ + Country-level choropleth for Russia (crosses antimeridian) should + produce valid paths with finite vertices on multiple projections. + """ + pytest.importorskip("cartopy.crs") + for proj in ("robin", "merc", "moll"): + fig, ax = uplt.subplots(proj=proj) + coll = ax[0].choropleth({"Russia": 1.0}, country=True, cmap="Glacial") + fig.canvas.draw() + + for path in coll.get_paths(): + verts = path.vertices + assert np.all(np.isfinite(verts)), ( + f"Russia choropleth path has non-finite vertices on {proj!r}" + ) + uplt.close(fig) + + def test_check_tricontourf(): """ Ensure transform defaults are applied only when appropriate for tri-plots. From b4cafcad289776519a71be20413e31451976e1be Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 31 Mar 2026 09:28:07 +1000 Subject: [PATCH 2/2] Black --- ultraplot/tests/test_geographic.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 5e0a50558..1d917f201 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -1137,9 +1137,9 @@ def test_choropleth_antimeridian_no_horizontal_artifacts(): assert len(paths) >= 1 codes = paths[0].codes moveto_count = (codes == 1).sum() # Path.MOVETO == 1 - assert moveto_count >= 2, ( - "Antimeridian-crossing polygon should be split into multiple sub-paths" - ) + assert ( + moveto_count >= 2 + ), "Antimeridian-crossing polygon should be split into multiple sub-paths" uplt.close(fig) @@ -1163,9 +1163,9 @@ def test_choropleth_project_geometry_non_cylindrical(): # Verify no inf/nan in projected vertices for path in paths: verts = path.vertices - assert np.all(np.isfinite(verts)), ( - f"Projected path has non-finite vertices on {proj!r} projection" - ) + assert np.all( + np.isfinite(verts) + ), f"Projected path has non-finite vertices on {proj!r} projection" uplt.close(fig) @@ -1182,9 +1182,9 @@ def test_choropleth_country_antimeridian_renders(): for path in coll.get_paths(): verts = path.vertices - assert np.all(np.isfinite(verts)), ( - f"Russia choropleth path has non-finite vertices on {proj!r}" - ) + assert np.all( + np.isfinite(verts) + ), f"Russia choropleth path has non-finite vertices on {proj!r}" uplt.close(fig)