Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2745d00
Extend map and coastlnes beyond 360 degrees
Oct 10, 2025
ac29503
Changed order of caching functions
Oct 11, 2025
02c0005
Removed maltron class and just using modified PlateCarree
Oct 11, 2025
84df329
Remove unnecessary changes (whitespace, prints etc)
Oct 11, 2025
53d442e
Added over paramter to CRS class and to PlateCarree and superclass
Oct 12, 2025
57548d6
Improved geometry duplication and fixed +over bugs
Oct 13, 2025
7f65d03
Allow for more than 360 degree duplication
Oct 15, 2025
54233f5
Added +over option to Geodetic(); reduced x_max
Oct 17, 2025
e2d409e
Rolled back all changes in feature_artist.
Oct 17, 2025
dcf40d1
Extended versions of tissot and coastlines methods.
Oct 17, 2025
511415b
Restored 572.95 degree limit; created self.over
Oct 19, 2025
116bb16
Fixed bug I introduced with coastline feature
Oct 20, 2025
4a88840
Changed self.bounds in Projection to reflect new maxima
Oct 21, 2025
e25b394
Begun modifications to extend images beyond 360 degrees
Oct 20, 2025
dd7dc63
Extend natural earth image, corrected bugs
Oct 24, 2025
0eb3e37
Nightshade now works for extended longitudes
Oct 25, 2025
9df3809
Corrected distortion errors in tissot_ext
Oct 25, 2025
96f1ea7
2 bug fixes
Oct 26, 2025
9558e5f
Added routines to extend data to longitudes > 360
Nov 2, 2025
559b4b7
Extend longitude with Mercator projection; bug fix
Nov 4, 2025
1c8757f
Removed +over from RotatedPole; docstring addition in util.py
Dec 2, 2025
e806b13
Removed unnecessary changes.
Dec 13, 2025
0c0a309
now uses self.projection.over to determine how tissot and coastlines
Dec 2, 2025
7d9f036
Removed coastlines_ext and tissot_ext as they are no longer needed
Dec 2, 2025
853473c
Removed a stray minus sign.
Dec 3, 2025
0bce468
Added an example routine, also serves as a test.
Dec 5, 2025
4b0b38e
Added test_over() to test_mercator.py
Dec 5, 2025
343f48a
Removed unintentially added tab which broke the tests.
Dec 5, 2025
bf71961
Projection checks if over is set before setting bounds
Dec 5, 2025
6e463e7
Moved import of xarray into function.
Dec 5, 2025
19573ce
Shortened over-long lines, removed unused variables.
Dec 5, 2025
7d0be52
Removed trailing whitespace
Dec 5, 2025
cf5c77c
Corrections made by ruff, all vestigial imports.
Dec 13, 2025
98c77fd
Corrected docstring and added titles to plots in example.
Dec 13, 2025
fb11ae4
Automatically fixed trailing whitespace
Dec 13, 2025
c33d812
Added mode="wrap" to the gaussian, which inexplicably disappeared.
Jan 12, 2026
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
133 changes: 133 additions & 0 deletions examples/scalar_data/extend_longitude_over_360.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Extending longitude beyond 360 degrees
======================================

This example demonstrates the use of the over parameter with
Cartopy. When using a cylindrical projection, setting over to True
engages proj's "+over" switch and enables longitudes to be extended
so that a single map can show more than 360 degrees of the globe. This
can be useful for ensuring that large structures can be shown in their
entirety, unbroken by the edge of the map.

The underlying data needs to be explicitly extended, for which the
utility routines extend_lons_np and extend_lons_xr are included in
cartopy.util. This allows for the possibility of non-repeated data,
such as an object's trajectory; a trivial example of this is shown
by plotting Chicago twice with different coloured points.

The script also applies Nightshade, which requires the "over"
parameter to be set, and tissot and coastlines, which work transparently.

Note that due a limitation in the underlying proj library, the longitudes
are limited to [-572.95, 572.95].
"""

import datetime

import matplotlib.pyplot as plt
import numpy as np
from scipy.ndimage import gaussian_filter

import cartopy.crs as ccrs
from cartopy.feature.nightshade import Nightshade
from cartopy.util import extend_lons_np


date = datetime.datetime.now()
#date = datetime.datetime(2025, 3, 20, 9, 1, 0) # NH Spring equinox 2025
#date = datetime.datetime(2025, 6, 21, 2, 42, 0) # NH Summer solstice 2025

# Coordinates of a few places
toulouse = 43.604500, 1.444000
nyalesund = 78.9, 11.9
chicago = 39.162, -84.45689

### Load some Copernicus ocean altimetry data
# ds = open_dataset("nrt_global_allsat_phy_l4_20241125_20241125.nc")
# u = ds.ugosa[0,2:-1:4,1::3]
# v = ds.vgosa[0,2:-1:4,1::3]
# u, v = ds.ugosa[0, ...] , ds.vgosa[0,...]
# speed = np.sqrt(u**2 + v**2)
# x, y = speed.longitude, speed.latitude

### Or just make up some random, pretend meteorological data
incr = 0.25
nlats, nlons = 720, 1440
lat = np.arange(incr/2, 90, incr)
lat = np.concat([-lat[-1::-1], lat])
lon = np.arange(incr/2, 180, incr)
lon = np.concat([-lon[-1::-1], lon])
size = (nlats, nlons)
rfield = np.random.normal(size=size)
rfield = np.concat((rfield, rfield), axis=1)
feature = gaussian_filter(rfield,
sigma=[nlats/12,nlons/4])[:,int(nlons/2):int(nlons*3/2)]

# var = speed
var = feature

# Extrema:
factor = 1
vmin = np.nanmin(var)/factor
vmax = np.nanmax(var)/factor
# # Make symmetrical
# vmin = -vmax

extend_cbar = "both"

def a_transform(arr):
"""Some random transformation to differentiate data."""
amax = arr.max()
return amax/2 - arr

# Design a few test configurations
mapconf1 = dict(title="Standard Mercator", lonmin=-180, lonmax=180, over=False,
trans=ccrs.PlateCarree, proj=ccrs.Mercator,
central_longitude=0)
mapconf2 = dict(title="Extended Mercator", lonmin=-390, lonmax=525, over=True,
trans=ccrs.PlateCarree, proj=ccrs.Mercator,
central_longitude=0)
mapconf3 = dict(title="Extended Plate Carrée", lonmin=-390, lonmax=525, over=True,
trans=ccrs.PlateCarree, proj=ccrs.PlateCarree,
central_longitude=0)

mapconfs = [mapconf1, mapconf2, mapconf3]

for ind, mapconf in enumerate(mapconfs[1:2]):
print(ind, mapconf)
lonmin, lonmax = mapconf["lonmin"], mapconf["lonmax"]
over = mapconf["over"]
central_longitude = mapconf["central_longitude"]
proj = mapconf["proj"]
trans = mapconf["trans"]
projection = proj(over=over, central_longitude=central_longitude)
transform = trans(over=over, central_longitude=central_longitude)
title = mapconf["title"] + " [" + str(lonmin) + "," + str(lonmax) + "]"
lon_ext, lat_ext, var_ext = extend_lons_np(lon, lat, var, lonmin, lonmax)
## Uncomment the following line to highlight the extra data (for Xarrays)
# var_ext = var_ext.where(
# np.abs(var_ext.longitude)<180, a_transform(var_ext))

fig = plt.figure(figsize=(10,4), facecolor=(0,0,0,0))
ax = plt.axes(projection=projection)
ax.pcolormesh(lon_ext, lat_ext, var_ext,
vmin=vmin, vmax = vmax, transform=transform)
ax.coastlines()
ax.gridlines(draw_labels=True, dms=True,
x_inline=False, y_inline=False,
crs=ccrs.PlateCarree(over=over))
ax.add_feature(Nightshade(date, alpha=0.2, delta=0.1, over=over))
ax.plot(toulouse[1], toulouse[0], "ro", transform=ccrs.Geodetic())
ax.plot(nyalesund[1], nyalesund[0], "ro", transform=ccrs.Geodetic())
ax.plot(chicago[1], chicago[0], "ro", transform=ccrs.Geodetic())
ax.plot(chicago[1]+360, chicago[0], "orange", marker="o", transform=ccrs.Geodetic())
ax.stock_img()
ax.tissot(
lons=np.arange(-590, 580, 200),
lats=[-75, -60, -45, -30, -10, 20, 50, 65, 80],
n_samples=40
)
ax.set_title(title)

plt.show()
#plt.savefig("eke_cartopy_extended_mercator.png")
60 changes: 45 additions & 15 deletions lib/cartopy/crs.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class is the very core of cartopy, all coordinate reference systems in cartopy
#: Whether this projection can handle ellipses.
_handles_ellipses = True

def __init__(self, proj4_params, globe=None):
def __init__(self, proj4_params, globe=None, over=False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having this as a separate keyword argument here, shouldn't this be a part of the proj4_params that gets passed in? The subclasses of this should update the proj4_params.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I thought it ought to be, that would be cleaner for sure. But I couldn't work out how to implement it nicely since all the other proj4_params that are passed have an argument, but +over does not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the same as you have added it to the list here already? i.e. something like proj4_params += [("over", None)]

"""
Parameters
----------
Expand All @@ -149,8 +149,23 @@ def __init__(self, proj4_params, globe=None):
globe: :class:`~cartopy.crs.Globe` instance, optional
If omitted, the default Globe instance will be created.
See :class:`~cartopy.crs.Globe` for details.
over: boolean, optional. Defaults to False.
If True, adds the +over parameter to the PROJ string,
and enables longitudes to be extended beyond 360
degrees. This only makes sense with cylindrical
projections, and enables more than one rotation of
the globe to be visualised.

"""

self.over = over
if over is True:
if isinstance(proj4_params, list):
proj4_params.append(("over", None))
elif isinstance(proj4_params, str):
proj4_params+" +over"
else:
print("Error: proj4_params neither str nor list")
self.input = (proj4_params, globe)

# for compatibility with pyproj.CRS and rasterio.crs.CRS
Expand Down Expand Up @@ -419,8 +434,9 @@ def transform_points(self, src_crs, x, y, z=None, trap=False):
self.is_geodetic()):
# convert from [0,360] to [-180,180]
x = np.array(x, copy=True)
to_180 = (x > 180) | (x < -180)
x[to_180] = (((x[to_180] + 180) % 360) - 180)
if self.over is False:
to_180 = (x > 180) | (x < -180)
x[to_180] = (((x[to_180] + 180) % 360) - 180)
Comment on lines +437 to +439
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting case. I guess this is us being nice to users and saying if you passed in 0-360 data we will automatically wrap that to -180 to 180 for you, but if we wanted to be more explicit this should be written as
PlateCarree(over=False).transform_points(..., transform=PlateCarree(over=True)) where the source transform has the over=True and the output doesn't.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in that case there would be no more reason to check, because you could use self.over instead of False in the first invocation of PlateCarree? I.e.

PlateCarree(over=self.over).transform_points(..., transform=PlateCarree(over=True))

try:
result[:, 0], result[:, 1], result[:, 2] = \
_safe_pj_transform(src_crs, self, x, y, z, trap=trap)
Expand Down Expand Up @@ -573,7 +589,7 @@ class Geodetic(CRS):

"""

def __init__(self, globe=None):
def __init__(self, globe=None, over=False):
"""
Parameters
----------
Expand All @@ -583,7 +599,7 @@ def __init__(self, globe=None):
"""
proj4_params = [('proj', 'lonlat')]
globe = globe or Globe(datum='WGS84')
super().__init__(proj4_params, globe)
super().__init__(proj4_params, globe, over=over)

# XXX Implement fwd such as Basemap's Geod.
# Would be used in the tissot example.
Expand Down Expand Up @@ -699,7 +715,12 @@ def __init__(self, *args, **kwargs):
elif self.is_geographic:
# If the projection is geographic without an area of use, assume
# the bounds are the full globe.
self.bounds = (-180, 180, -90, 90)
# So that feature_artist can handle +over, the
# bounds are extended beyond +/- 180 degrees.
if self.over:
self.bounds = (-572.95, 572.95, -90, 90)
else:
self.bounds = (-180, 180, -90, 90)

@property
def boundary(self):
Expand Down Expand Up @@ -1305,10 +1326,10 @@ class _RectangularProjection(Projection, metaclass=ABCMeta):
"""
_wrappable = True

def __init__(self, proj4_params, half_width, half_height, globe=None):
def __init__(self, proj4_params, half_width, half_height, globe=None, over=False):
self._half_width = half_width
self._half_height = half_height
super().__init__(proj4_params, globe=globe)
super().__init__(proj4_params, globe=globe, over=over)

@property
def boundary(self):
Expand Down Expand Up @@ -1348,17 +1369,20 @@ def _ellipse_boundary(semimajor=2, semiminor=1, easting=0, northing=0, n=201):


class PlateCarree(_CylindricalProjection):
def __init__(self, central_longitude=0.0, globe=None):
def __init__(self, central_longitude=0.0, globe=None, over=False):
globe = globe or Globe(semimajor_axis=WGS84_SEMIMAJOR_AXIS)
proj4_params = [('proj', 'eqc'), ('lon_0', central_longitude),
('to_meter', math.radians(1) * (
globe.semimajor_axis or WGS84_SEMIMAJOR_AXIS)),
('vto_meter', 1)]
x_max = 180
if over is True:
x_max = 572.95 # Maximum allowed value in pyproj with +over
else:
x_max = 180
y_max = 90
# Set the threshold around 0.5 if the x max is 180.
self.threshold = x_max / 360
super().__init__(proj4_params, x_max, y_max, globe=globe)
self.threshold = 0.5
super().__init__(proj4_params, x_max, y_max, globe=globe, over=over)

def _bbox_and_offset(self, other_plate_carree):
"""
Expand Down Expand Up @@ -1628,7 +1652,8 @@ class Mercator(Projection):
def __init__(self, central_longitude=0.0,
min_latitude=-80.0, max_latitude=84.0,
globe=None, latitude_true_scale=None,
false_easting=0.0, false_northing=0.0, scale_factor=None):
false_easting=0.0, false_northing=0.0, scale_factor=None,
over=False):
"""
Parameters
----------
Expand All @@ -1650,6 +1675,8 @@ def __init__(self, central_longitude=0.0,
Y offset from the planar origin in metres. Defaults to 0.
scale_factor: optional
Scale factor at natural origin. Defaults to unused.
over: optional
Extend map beyond 360 degrees. Defaults to False.
Comment on lines +1678 to +1679
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize that "over" is what PROJ uses, but I wonder if there is a more descriptive word we should use for these classes since it doesn't really mean much to me when first looking at this. "extend map beyond 360 degrees" might have some more description about how it ignores the (-180, 180) wrap and where the limits are and what happens when you go beyond them in this case.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I admit I hesitated on this point. I shall rewrite and clarify the description, but I'm unsure about the parameter name. Possible options could be extend_lon, no_wrap, repeat_lons, cycle_lon, over_lon....


Notes
-----
Expand All @@ -1674,13 +1701,16 @@ def __init__(self, central_longitude=0.0,
else:
proj4_params.append(('k_0', scale_factor))

super().__init__(proj4_params, globe=globe)
super().__init__(proj4_params, globe=globe, over=over)

# Need to have x/y limits defined for the initial hash which
# gets used within transform_points for caching
self._x_limits = self._y_limits = None
# Calculate limits.
minlon, maxlon = self._determine_longitude_bounds(central_longitude)
if over is False:
minlon, maxlon = self._determine_longitude_bounds(central_longitude)
else:
minlon, maxlon = -572.95, 572.95
Comment on lines +1711 to +1713
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens with +over when supplied with central longitude? Do we need to put the bounds check in the _determine_longitude_bounds() function call?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Naively, I left this alone since it ceases to have any meaning when the longitudes are set by the extent of the map with over=True. When over=False everything is as normal, but with over=True it churns and doesn't appear to work, I got tired of waiting and killed it. I wonder if this is because I have hard set the bounds, instead of setting them to the data? A simple fix would be to force central_longitude to be equal to 0 when over=True. I'll dig into it, including looking at _determine_longitude_bounds().

limits = self.transform_points(self.as_geodetic(),
np.array([minlon, maxlon]),
np.array([min_latitude, max_latitude]))
Expand Down
87 changes: 87 additions & 0 deletions lib/cartopy/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from abc import ABCMeta, abstractmethod

import numpy as np
import shapely.affinity as saffinity
import shapely.geometry as sgeom

import cartopy.crs
Expand Down Expand Up @@ -286,6 +287,7 @@ def geometries(self):
Returns an iterator of (shapely) geometries for this feature.

"""

key = (self.name, self.category, self.scale)
if key not in _NATURAL_EARTH_GEOM_CACHE:
path = shapereader.natural_earth(resolution=self.scale,
Expand Down Expand Up @@ -326,6 +328,91 @@ def with_scale(self, new_scale):
return NaturalEarthFeature(self.category, self.name, new_scale,
**self.kwargs)

class NaturalEarthFeature_ext(NaturalEarthFeature):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what this is used for? I have to read the code currently because there is no overview docstring.

The name should be CamelCase

Suggested change
class NaturalEarthFeature_ext(NaturalEarthFeature):
class NaturalEarthFeatureExtended(NaturalEarthFeature):

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to enable the extension of the coastlines beyond 360 degrees. I found that trying to do that within FeatureArtist broke other functionality, so I decided it was best to leave FeatureArtist agnostic with regard to whether +over was set. (I have a branch called extend_in_feature_artist in my cartopy-extlon repository, if you want to see how I tried to implement it.) As a result, FeatureArtist makes no assumptions about the data it is fed, and the unmodified NaturalEarthFeature will work, but will only cover 360 degrees of the map. So the subclass NaturalEarthFeature_ext will repeat the coastlines as many times as necessary to cover the extent defined by the minimum and maximum longitudes set when plotting. (See commit 116bb16. ) Since NaturalEarthFeature is invoked to produce hard-coded feature variables in __init__.py, I could not easily get it to read in the extent beforehand, thus my decision to explicitly call it in coastlines(). It is probably better to implement the changes within the original NaturalEarthFeature, but I wanted to tread lightly for now, so I left it as a separate class, thinking that since the user doesn't see it it should be transparent for them. As for the name, I will amend it, but I'll wait until we have settled on a name for the over variable.

Incidentally, the problem I had with my original approach of extending features in FeatureArtist informed most of my decisions henceforth, and it was why I settled on implementing over on a case-by-case basis for each feature (I have not done them all, since I wasn't sure how this would be received, but I intend to). This speaks somewhat to your comment about playing "whack-a-mole" - I'll say more on that as a direct reply to that comment, but this is part of why it has ended up this way. It has the benefit of not requiring FeatureArtist to care about the feature, so a feature could conceivably only cover a single cycle, in case that was desired, but the flip side is that features which are intended to repeat must be written to be "over-aware", so to speak.

def __init__(self, category, name, scale, extent=None, **kwargs):
"""
Parameters
----------
category
The category of the dataset, i.e. either 'cultural' or 'physical'.
name
The name of the dataset, e.g. 'admin_0_boundary_lines_land'.
scale
The dataset scale, i.e. one of '10m', '50m', or '110m',
or Scaler object. Dataset scales correspond to 1:10,000,000,
1:50,000,000, and 1:110,000,000 respectively.

Other Parameters
----------------
**kwargs
Keyword arguments to be used when drawing this feature.

"""

# Set over to True for all cases /maltron
super(NaturalEarthFeature, self).__init__(
cartopy.crs.PlateCarree(over=True), **kwargs
)
self.category = category
self.name = name

# Cast the given scale to a (constant) Scaler if a string is passed.
if isinstance(scale, str):
scale = Scaler(scale)

self.scaler = scale
# Make sure this is a valid resolution
self._validate_scale()
self.extent = extent

def geometries(self):
"""
Returns an iterator of (shapely) geometries for this feature.

"""
key = (self.name, self.category, self.scale)
if key not in _NATURAL_EARTH_GEOM_CACHE:
path = shapereader.natural_earth(resolution=self.scale,
category=self.category,
name=self.name)
reader = shapereader.Reader(path)
if reader.crs is not None:
self._crs = reader.crs
geometries = tuple(reader.geometries())
_NATURAL_EARTH_GEOM_CACHE[key] = geometries
else:
geometries = _NATURAL_EARTH_GEOM_CACHE[key]

if self.extent is not None:
if self.extent[1] - self.extent[0] > 360:
def extend_geoms(geoms, extent, xoffset=360):
extent_geom = sgeom.box(extent[0], extent[2],
extent[1], extent[3])
new_geoms = []
for geom in geoms:
geom_ext = saffinity.translate(geom, xoff=xoffset, yoff=0)
if extent_geom.intersects(geom_ext):
new_geoms.append(geom_ext)
return new_geoms

geoms_left = []
geoms_right = []
if self.extent[1]-self.extent[0] > 360:
if self.extent[0] < -180:
for offset in np.arange(-360, self.extent[0]-360, -360):
geoms_left += extend_geoms(
geometries, self.extent, xoffset=offset
)

if self.extent[1] > 180:
for offset in np.arange(360, self.extent[1]+360, 360):
geoms_right += extend_geoms(
geometries, self.extent, xoffset=offset
)

geometries = tuple(geoms_left) + geometries + tuple(geoms_right)

return iter(geometries)

class GSHHSFeature(Feature):
"""
Expand Down
Loading
Loading