Skip to content

Projection Module — Projected ("Globe") Map Frames & Presets#

The cleopatra.basemap.projection module has two layers:

  1. apply_projection_frame — the low-level, stateless renderer. It turns a plain matplotlib Axes into a static projected ("globe") frame: it sets the projected limits and equal aspect, draws the projection boundary (the globe's circle, Robinson's rounded rectangle, ...), optionally clips the existing data layers to that boundary, and draws the graticule polylines. This function is pure matplotlib with no PROJ/CRS dependency — it only receives already-computed geometry (boundary vertices, graticule polylines, projected limits) as plain arrays and renders it.

  2. Orthographic globe presets — higher-level helpers that do the reprojection for the common orthographic ("globe") case, and therefore require pyproj (the cleopatra[tiles] extra): apply_projection_style (the one-call entry point, driven by PROJECTION_STYLES), orthographic_grid / orthographic_grid_edges (reproject a lon/lat grid, masking the far hemisphere), orthographic_points (reproject scattered lon/lat points), and orthographic_boundary / orthographic_graticule (the globe outline and meridian/parallel polylines). apply_projection_style(style="globe") reprojects (lon, lat, data) and draws the boundary + graticule via apply_projection_frame; pair it with colors.apply_data_style to compose the full CAMS "haze" globe in a couple of lines (see the Haze-style presets example notebook).

If you already have projected geometry from an upstream engine, use apply_projection_frame directly and skip the pyproj-backed presets.

Usage#

import matplotlib
matplotlib.use("Agg")  # any backend; Agg shown for headless rendering
import numpy as np
import matplotlib.pyplot as plt

from cleopatra.basemap.projection import apply_projection_frame

# Geometry comes in as plain arrays (here a unit-circle globe outline and a
# meridian); upstream produces the real projected boundary/graticule.
theta = np.linspace(0, 2 * np.pi, 200)
boundary = np.column_stack([np.cos(theta), np.sin(theta)])
meridian = np.column_stack([np.zeros(50), np.linspace(-1, 1, 50)])

fig, ax = plt.subplots()
# plot your (already reprojected) data first so it can be clipped ...
ax.imshow(np.zeros((8, 8)), extent=(-1, 1, -1, 1))

# ... then frame the axes as a globe and clip the data to the boundary
patch = apply_projection_frame(
    ax,
    boundary_xy=boundary,
    xlim=(-1, 1),
    ylim=(-1, 1),
    graticule_lines=[meridian],
)

fig.savefig("globe.png")

Note

apply_projection_frame performs no reprojection — pass already-densified, already-projected geometry as (N, 2) arrays. It is a one-shot helper: each call appends a fresh boundary patch and graticule lines, so apply it once per axes (create a new axes to re-frame). With clip_artists=True (default) every existing ax.images/ax.collections/ax.lines artist — and the graticule — is clipped to the boundary; pass clip_artists=False to leave the data layers unclipped. Style the boundary and graticule via boundary_kw / graticule_kw, which override DEFAULT_BOUNDARY_KW / DEFAULT_GRATICULE_KW.

Module Documentation#

cleopatra.basemap.projection #

Static projected ('globe') map frame for matplotlib axes.

Provides apply_projection_frame -- a single stateless helper that turns a plain matplotlib.axes.Axes into a static projected map frame: it sets the projected limits and equal aspect, draws the projection boundary (the globe's circle, Robinson's rounded rectangle, ...), optionally clips the existing data layers to that boundary, and draws the graticule polylines.

The module is pure matplotlib with no PROJ/CRS dependency. It only receives already-computed geometry -- boundary vertices, graticule polylines, and projected limits -- as plain arrays. Whatever produces the projection (reprojecting data and deriving the boundary/graticule) lives upstream; cleopatra only renders the result. This keeps the engine split clean: the upstream owns CRS/PROJ, cleopatra owns matplotlib.

Examples:

Frame a plain axes as an orthographic globe and clip an image to the boundary circle:

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> theta = np.linspace(0, 2 * np.pi, 200)
>>> boundary = np.column_stack([np.cos(theta), np.sin(theta)])
>>> fig, ax = plt.subplots()
>>> _ = ax.imshow(np.random.rand(8, 8), extent=(-1, 1, -1, 1))
>>> patch = apply_projection_frame(
...     ax, boundary_xy=boundary, xlim=(-1, 1), ylim=(-1, 1)
... )
>>> ax.get_aspect()
1.0

apply_projection_frame(ax, *, boundary_xy, xlim, ylim, graticule_lines=None, clip_artists=True, boundary_kw=None, graticule_kw=None) #

Turn a plain axes into a static projected ('globe') frame.

Sets equal aspect and the projected x/y limits, draws the projection boundary as a matplotlib.patches.PathPatch, draws the graticule polylines, optionally clips the existing data layers to the boundary, and turns the axis decorations off. The boundary geometry, graticule polylines, and limits are supplied as plain arrays -- this function performs no reprojection and has no PROJ/CRS dependency.

This is a one-shot helper: each call appends a fresh boundary patch and a fresh set of graticule lines, so calling it twice on the same axes stacks duplicate artists. Apply it once per axes (create a new axes to re-frame).

Parameters:

Name Type Description Default
ax Any

Matplotlib matplotlib.axes.Axes to frame. Any data layers to be clipped should already be plotted.

required
boundary_xy Any

(N, 2) projection-boundary vertices in projected coordinates (e.g. the globe's circle). Array-like.

required
xlim Sequence[float]

Projected-coordinate x-limits (xmin, xmax) -- the CRS domain in the x direction.

required
ylim Sequence[float]

Projected-coordinate y-limits (ymin, ymax).

required
graticule_lines Sequence[Any] | None

Optional list of (M, 2) polylines (already densified and projected), each drawn as a graticule line. None (default) draws no graticule.

None
clip_artists bool

If True (default), clip the existing data layers (ax.images, ax.collections, ax.lines) and the drawn graticule to the boundary path.

True
boundary_kw dict[str, Any] | None

Style overrides for the boundary patch, merged over DEFAULT_BOUNDARY_KW. facecolor defaults to "none" so the patch never hides the data.

None
graticule_kw dict[str, Any] | None

Style overrides for the graticule lines, merged over DEFAULT_GRATICULE_KW.

None

Returns:

Type Description
PathPatch

matplotlib.patches.PathPatch: The boundary patch added to the

PathPatch

axes (also used as the clip path).

Raises:

Type Description
TypeError

If ax is not a matplotlib Axes.

ValueError

If boundary_xy or a graticule line is not an (N, 2) array, or if xlim/ylim are not 2-tuples.

Examples:

  • Frame a plain axes as a globe and read back the result: the returned patch is registered on the axes and the aspect is equal:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_frame
    >>> theta = np.linspace(0, 2 * np.pi, 200)
    >>> boundary = np.column_stack([np.cos(theta), np.sin(theta)])
    >>> fig, ax = plt.subplots()
    >>> patch = apply_projection_frame(
    ...     ax, boundary_xy=boundary, xlim=(-1, 1), ylim=(-1, 1)
    ... )
    >>> ax.get_aspect()
    1.0
    >>> patch in ax.patches
    True
    
  • Clip a data image and draw one graticule line (plain lists are accepted): the image gains a clip path and one line is added:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_frame
    >>> boundary = [[1, 0], [0, 1], [-1, 0], [0, -1]]
    >>> meridian = [[0, -1], [0, 1]]
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.zeros((4, 4)), extent=(-1, 1, -1, 1))
    >>> patch = apply_projection_frame(
    ...     ax,
    ...     boundary_xy=boundary,
    ...     xlim=(-1, 1),
    ...     ylim=(-1, 1),
    ...     graticule_lines=[meridian],
    ... )
    >>> im.get_clip_path() is not None
    True
    >>> len(ax.lines)
    1
    
  • Passing a non-Axes object raises TypeError:
    >>> from cleopatra.basemap.projection import apply_projection_frame
    >>> apply_projection_frame(
    ...     object(), boundary_xy=[[0, 0], [1, 1]], xlim=(-1, 1), ylim=(-1, 1)
    ... )
    Traceback (most recent call last):
        ...
    TypeError: ax must be a matplotlib.axes.Axes instance, got object
    
Source code in src/cleopatra/basemap/projection.py
def apply_projection_frame(
    ax: Any,
    *,
    boundary_xy: Any,
    xlim: Sequence[float],
    ylim: Sequence[float],
    graticule_lines: Sequence[Any] | None = None,
    clip_artists: bool = True,
    boundary_kw: dict[str, Any] | None = None,
    graticule_kw: dict[str, Any] | None = None,
) -> PathPatch:
    """Turn a plain axes into a static projected ('globe') frame.

    Sets equal aspect and the projected x/y limits, draws the projection
    boundary as a `matplotlib.patches.PathPatch`, draws the graticule
    polylines, optionally clips the existing data layers to the boundary,
    and turns the axis decorations off. The boundary geometry, graticule
    polylines, and limits are supplied as plain arrays -- this function
    performs no reprojection and has no PROJ/CRS dependency.

    This is a one-shot helper: each call appends a fresh boundary patch and
    a fresh set of graticule lines, so calling it twice on the same axes
    stacks duplicate artists. Apply it once per axes (create a new axes to
    re-frame).

    Args:
        ax: Matplotlib `matplotlib.axes.Axes` to frame. Any data
            layers to be clipped should already be plotted.
        boundary_xy: ``(N, 2)`` projection-boundary vertices in projected
            coordinates (e.g. the globe's circle). Array-like.
        xlim: Projected-coordinate x-limits ``(xmin, xmax)`` -- the CRS
            domain in the x direction.
        ylim: Projected-coordinate y-limits ``(ymin, ymax)``.
        graticule_lines: Optional list of ``(M, 2)`` polylines (already
            densified and projected), each drawn as a graticule line.
            `None` (default) draws no graticule.
        clip_artists: If `True` (default), clip the existing data layers
            (`ax.images`, `ax.collections`, `ax.lines`) and the drawn
            graticule to the boundary path.
        boundary_kw: Style overrides for the boundary patch, merged over
            `DEFAULT_BOUNDARY_KW`. ``facecolor`` defaults to
            ``"none"`` so the patch never hides the data.
        graticule_kw: Style overrides for the graticule lines, merged
            over `DEFAULT_GRATICULE_KW`.

    Returns:
        matplotlib.patches.PathPatch: The boundary patch added to the
        axes (also used as the clip path).

    Raises:
        TypeError: If `ax` is not a matplotlib Axes.
        ValueError: If `boundary_xy` or a graticule line is not an
            ``(N, 2)`` array, or if `xlim`/`ylim` are not 2-tuples.

    Examples:
        - Frame a plain axes as a globe and read back the result: the
            returned patch is registered on the axes and the aspect is equal:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_frame
            >>> theta = np.linspace(0, 2 * np.pi, 200)
            >>> boundary = np.column_stack([np.cos(theta), np.sin(theta)])
            >>> fig, ax = plt.subplots()
            >>> patch = apply_projection_frame(
            ...     ax, boundary_xy=boundary, xlim=(-1, 1), ylim=(-1, 1)
            ... )
            >>> ax.get_aspect()
            1.0
            >>> patch in ax.patches
            True

            ```
        - Clip a data image and draw one graticule line (plain lists are
            accepted): the image gains a clip path and one line is added:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_frame
            >>> boundary = [[1, 0], [0, 1], [-1, 0], [0, -1]]
            >>> meridian = [[0, -1], [0, 1]]
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.zeros((4, 4)), extent=(-1, 1, -1, 1))
            >>> patch = apply_projection_frame(
            ...     ax,
            ...     boundary_xy=boundary,
            ...     xlim=(-1, 1),
            ...     ylim=(-1, 1),
            ...     graticule_lines=[meridian],
            ... )
            >>> im.get_clip_path() is not None
            True
            >>> len(ax.lines)
            1

            ```
        - Passing a non-Axes object raises ``TypeError``:
            ```python
            >>> from cleopatra.basemap.projection import apply_projection_frame
            >>> apply_projection_frame(
            ...     object(), boundary_xy=[[0, 0], [1, 1]], xlim=(-1, 1), ylim=(-1, 1)
            ... )
            Traceback (most recent call last):
                ...
            TypeError: ax must be a matplotlib.axes.Axes instance, got object

            ```
    """
    if not hasattr(ax, "set_xlim") or not hasattr(ax, "add_patch"):
        raise TypeError(
            f"ax must be a matplotlib.axes.Axes instance, got {type(ax).__name__}"
        )

    if len(xlim) != 2 or len(ylim) != 2:
        raise ValueError(
            f"xlim and ylim must each be a (min, max) pair, "
            f"got xlim={xlim!r}, ylim={ylim!r}."
        )

    boundary = _as_xy(boundary_xy, "boundary_xy")

    ax.set_aspect("equal")
    ax.set_xlim(xlim[0], xlim[1])
    ax.set_ylim(ylim[0], ylim[1])

    patch = PathPatch(
        Path(boundary),
        facecolor="none",
        **{**DEFAULT_BOUNDARY_KW, **(boundary_kw or {})},
    )
    ax.add_patch(patch)

    graticule_style = {**DEFAULT_GRATICULE_KW, **(graticule_kw or {})}
    graticule_artists = []
    for i, line in enumerate(graticule_lines or []):
        xy = _as_xy(line, f"graticule_lines[{i}]")
        (artist,) = ax.plot(xy[:, 0], xy[:, 1], **graticule_style)
        graticule_artists.append(artist)

    if clip_artists:
        for art in (*ax.images, *ax.collections, *ax.lines):
            art.set_clip_path(patch)

    ax.set_axis_off()
    return patch

apply_projection_style(ax, lon, lat, data, style='globe', *, draw_frame=True, **overrides) #

Reproject (lon, lat, data) and frame ax per a PROJECTION_STYLES preset.

For "globe", reprojects via orthographic_grid and draws the globe boundary + graticule on ax via apply_projection_frame. For "flat", returns (lon, lat, data) unchanged and does not touch ax at all -- the data is meant to be plotted directly in lon/lat coordinates.

This is the projection half of composing the haze look; the colour half is cleopatra.styling.colors.apply_data_style, called with the (x, y, data) this function returns as its x/y/layer arguments. Both styles return cell-edge coordinates (one larger per axis than data) for use with shading="flat": the orthographic projection's extreme distortion near its centre makes matplotlib's automatic centre-to-edge inference (shading="auto"/"nearest") unreliable -- most cells can render as degenerate slivers -- so edges are computed explicitly and reliably instead. The same two-line pattern composes both layers regardless of which style was chosen:

x, y, masked = apply_projection_style(ax, lon, lat, data, style=chosen)
apply_data_style(ax, {"dust": masked}, x=x, y=y, shading="flat")

Neither function requires the other: use "globe" with a single plain colormap instead of apply_data_style, or "flat" with the "haze" data style and no globe at all.

This function takes a single data array, not a layers dict like apply_data_style -- drawing several layers on the same grid/axes means calling it once per layer. apply_projection_frame (which draws the boundary/graticule) is one-shot per axes: a second unguarded call stacks a duplicate boundary patch and graticule. Pass draw_frame=False on every call after the first to reproject/mask that layer's data without redrawing the chrome:

x, y, om = apply_projection_style(ax, lon, lat, organic_matter, style="globe")
_, _, du = apply_projection_style(ax, lon, lat, dust, style="globe", draw_frame=False)

Parameters:

Name Type Description Default
ax Axes

Axes to draw the boundary/graticule on. Ignored for "flat" (which never draws) and when draw_frame=False.

required
lon Any

1D vector of cell-centre longitudes, degrees. Both styles require 1D lon/lat so cell edges can be computed reliably; for an already-2D curvilinear grid, call orthographic_grid directly and build your own edge coordinates.

required
lat Any

1D vector of cell-centre latitudes, degrees, paired with lon.

required
data Any

2D array of values, shape (len(lat), len(lon)). Reprojected and hemisphere-masked for "globe"; returned unchanged for "flat".

required
style str

A name from PROJECTION_STYLES ("globe" or "flat").

'globe'
draw_frame bool

If True (default), draw the boundary/graticule via apply_projection_frame ("globe" only -- "flat" never draws regardless). Pass False for every call after the first when drawing multiple layers on one already-framed axes.

True
**overrides Any

Override any of the style's parameters -- for "globe": center_lat, center_lon, graticule_step, boundary_kw, graticule_kw (the last two forwarded to apply_projection_frame).

{}

Returns:

Name Type Description
tuple ndarray

(x_edges, y_edges, data) -- cell-edge coordinates, each

ndarray

shaped (len(lat) + 1, len(lon) + 1), and data unchanged (for

ndarray

"flat") or hemisphere-masked (for "globe").

Raises:

Type Description
KeyError

If style is not registered.

ValueError

If lon/lat are not 1D.

ImportError

If style="globe" and pyproj (the [tiles] extra) is not installed.

Examples:

  • "globe" reprojects the data, draws a boundary patch on ax, and returns edge coordinates one larger per axis than data:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_style
    >>> fig, ax = plt.subplots()
    >>> lon = np.array([0.0, 90.0])
    >>> lat = np.array([90.0, -90.0])
    >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
    >>> x, y, masked = apply_projection_style(ax, lon, lat, data, style="globe")
    >>> len(ax.patches)  # the boundary circle
    1
    >>> x.shape  # one larger per axis than data's (2, 2)
    (3, 3)
    >>> np.all(np.isnan(masked[1]))  # far hemisphere still masked
    np.True_
    >>> plt.close(fig)
    
  • "flat" draws nothing but still returns matching edge coordinates, so the same shading="flat" call works for either style:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_style
    >>> fig, ax = plt.subplots()
    >>> lon = np.array([0.0, 90.0])
    >>> lat = np.array([90.0, -90.0])
    >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
    >>> x, y, out = apply_projection_style(ax, lon, lat, data, style="flat")
    >>> len(ax.patches)
    0
    >>> x.shape
    (3, 3)
    >>> np.array_equal(out, data)
    True
    >>> plt.close(fig)
    
  • A second layer on the same globe with draw_frame=False reuses the already-drawn chrome instead of stacking a duplicate boundary:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_style
    >>> fig, ax = plt.subplots()
    >>> lon = np.array([0.0, 90.0])
    >>> lat = np.array([90.0, -90.0])
    >>> first = np.array([[1.0, 2.0], [3.0, 4.0]])
    >>> second = np.array([[5.0, 6.0], [7.0, 8.0]])
    >>> _ = apply_projection_style(ax, lon, lat, first, style="globe")
    >>> _ = apply_projection_style(ax, lon, lat, second, style="globe", draw_frame=False)
    >>> len(ax.patches)  # still one boundary, not two
    1
    >>> plt.close(fig)
    
  • An unknown style raises KeyError:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.basemap.projection import apply_projection_style
    >>> fig, ax = plt.subplots()
    >>> apply_projection_style(
    ...     ax, np.array([0.0]), np.array([0.0]), np.array([[1.0]]),
    ...     style="not-a-style",
    ... )
    Traceback (most recent call last):
        ...
    KeyError: "Unknown projection style 'not-a-style'; available: ['flat', 'globe']"
    >>> plt.close(fig)
    
See Also

orthographic_grid: The "globe" cell-centre reprojection primitive. orthographic_grid_edges: The "globe" cell-edge reprojection primitive. apply_projection_frame: Draws the boundary/graticule this composes. cleopatra.styling.colors.apply_data_style: The companion data-style axis.

Source code in src/cleopatra/basemap/projection.py
def apply_projection_style(
    ax: Axes,
    lon: Any,
    lat: Any,
    data: Any,
    style: str = "globe",
    *,
    draw_frame: bool = True,
    **overrides: Any,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Reproject `(lon, lat, data)` and frame `ax` per a `PROJECTION_STYLES` preset.

    For `"globe"`, reprojects via `orthographic_grid` and draws the globe
    boundary + graticule on `ax` via `apply_projection_frame`. For `"flat"`,
    returns `(lon, lat, data)` unchanged and does not touch `ax` at all --
    the data is meant to be plotted directly in lon/lat coordinates.

    This is the projection half of composing the haze look; the colour half
    is `cleopatra.styling.colors.apply_data_style`, called with the `(x, y, data)`
    this function returns as its `x`/`y`/layer arguments. Both styles return
    cell-**edge** coordinates (one larger per axis than `data`) for use with
    `shading="flat"`: the orthographic projection's extreme distortion near
    its centre makes matplotlib's automatic centre-to-edge inference
    (`shading="auto"`/`"nearest"`) unreliable -- most cells can render as
    degenerate slivers -- so edges are computed explicitly and reliably
    instead. The *same* two-line pattern composes both layers regardless of
    which style was chosen:

    ```python
    x, y, masked = apply_projection_style(ax, lon, lat, data, style=chosen)
    apply_data_style(ax, {"dust": masked}, x=x, y=y, shading="flat")
    ```

    Neither function requires the other: use `"globe"` with a single plain
    colormap instead of `apply_data_style`, or `"flat"` with the `"haze"`
    data style and no globe at all.

    This function takes a single `data` array, not a `layers` dict like
    `apply_data_style` -- drawing several layers on the *same* grid/axes
    means calling it once per layer. `apply_projection_frame` (which draws
    the boundary/graticule) is one-shot per axes: a second unguarded call
    stacks a duplicate boundary patch and graticule. Pass `draw_frame=False`
    on every call after the first to reproject/mask that layer's data
    without redrawing the chrome:

    ```python
    x, y, om = apply_projection_style(ax, lon, lat, organic_matter, style="globe")
    _, _, du = apply_projection_style(ax, lon, lat, dust, style="globe", draw_frame=False)
    ```

    Args:
        ax: Axes to draw the boundary/graticule on. Ignored for `"flat"`
            (which never draws) and when `draw_frame=False`.
        lon: 1D vector of cell-centre longitudes, degrees. Both styles
            require 1D `lon`/`lat` so cell edges can be computed reliably;
            for an already-2D curvilinear grid, call `orthographic_grid`
            directly and build your own edge coordinates.
        lat: 1D vector of cell-centre latitudes, degrees, paired with `lon`.
        data: 2D array of values, shape `(len(lat), len(lon))`. Reprojected
            and hemisphere-masked for `"globe"`; returned unchanged for
            `"flat"`.
        style: A name from `PROJECTION_STYLES` (`"globe"` or `"flat"`).
        draw_frame: If `True` (default), draw the boundary/graticule via
            `apply_projection_frame` (`"globe"` only -- `"flat"` never draws
            regardless). Pass `False` for every call after the first when
            drawing multiple layers on one already-framed axes.
        **overrides: Override any of the style's parameters -- for
            `"globe"`: `center_lat`, `center_lon`, `graticule_step`,
            `boundary_kw`, `graticule_kw` (the last two forwarded to
            `apply_projection_frame`).

    Returns:
        tuple: `(x_edges, y_edges, data)` -- cell-edge coordinates, each
        shaped `(len(lat) + 1, len(lon) + 1)`, and `data` unchanged (for
        `"flat"`) or hemisphere-masked (for `"globe"`).

    Raises:
        KeyError: If `style` is not registered.
        ValueError: If `lon`/`lat` are not 1D.
        ImportError: If `style="globe"` and pyproj (the `[tiles]` extra) is
            not installed.

    Examples:
        - `"globe"` reprojects the data, draws a boundary patch on `ax`, and
          returns edge coordinates one larger per axis than `data`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_style
            >>> fig, ax = plt.subplots()
            >>> lon = np.array([0.0, 90.0])
            >>> lat = np.array([90.0, -90.0])
            >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
            >>> x, y, masked = apply_projection_style(ax, lon, lat, data, style="globe")
            >>> len(ax.patches)  # the boundary circle
            1
            >>> x.shape  # one larger per axis than data's (2, 2)
            (3, 3)
            >>> np.all(np.isnan(masked[1]))  # far hemisphere still masked
            np.True_
            >>> plt.close(fig)

            ```
        - `"flat"` draws nothing but still returns matching edge coordinates,
          so the same `shading="flat"` call works for either style:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_style
            >>> fig, ax = plt.subplots()
            >>> lon = np.array([0.0, 90.0])
            >>> lat = np.array([90.0, -90.0])
            >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
            >>> x, y, out = apply_projection_style(ax, lon, lat, data, style="flat")
            >>> len(ax.patches)
            0
            >>> x.shape
            (3, 3)
            >>> np.array_equal(out, data)
            True
            >>> plt.close(fig)

            ```
        - A second layer on the same globe with `draw_frame=False` reuses the
          already-drawn chrome instead of stacking a duplicate boundary:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_style
            >>> fig, ax = plt.subplots()
            >>> lon = np.array([0.0, 90.0])
            >>> lat = np.array([90.0, -90.0])
            >>> first = np.array([[1.0, 2.0], [3.0, 4.0]])
            >>> second = np.array([[5.0, 6.0], [7.0, 8.0]])
            >>> _ = apply_projection_style(ax, lon, lat, first, style="globe")
            >>> _ = apply_projection_style(ax, lon, lat, second, style="globe", draw_frame=False)
            >>> len(ax.patches)  # still one boundary, not two
            1
            >>> plt.close(fig)

            ```
        - An unknown style raises `KeyError`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.basemap.projection import apply_projection_style
            >>> fig, ax = plt.subplots()
            >>> apply_projection_style(
            ...     ax, np.array([0.0]), np.array([0.0]), np.array([[1.0]]),
            ...     style="not-a-style",
            ... )
            Traceback (most recent call last):
                ...
            KeyError: "Unknown projection style 'not-a-style'; available: ['flat', 'globe']"
            >>> plt.close(fig)

            ```

    See Also:
        orthographic_grid: The `"globe"` cell-centre reprojection primitive.
        orthographic_grid_edges: The `"globe"` cell-edge reprojection primitive.
        apply_projection_frame: Draws the boundary/graticule this composes.
        cleopatra.styling.colors.apply_data_style: The companion data-style axis.
    """
    if style not in PROJECTION_STYLES:
        raise KeyError(
            f"Unknown projection style {style!r}; available: "
            f"{sorted(PROJECTION_STYLES)}"
        )
    cfg = {**PROJECTION_STYLES[style], **overrides}

    lon_arr = np.asarray(lon, dtype=float)
    lat_arr = np.asarray(lat, dtype=float)
    if lon_arr.ndim != 1 or lat_arr.ndim != 1:
        raise ValueError(
            "apply_projection_style requires 1D lon/lat vectors so cell "
            "edges can be computed reliably; for an already-2D curvilinear "
            "grid, call orthographic_grid directly and build your own edge "
            "coordinates."
        )

    if style == "flat":
        lon_edges_1d = _bin_edges(lon_arr)
        lat_edges_1d = _bin_edges(lat_arr)
        x_edges, y_edges = np.meshgrid(lon_edges_1d, lat_edges_1d)
        return x_edges, y_edges, np.asarray(data, dtype=float)

    center_lat = cfg.get("center_lat", 90.0)
    center_lon = cfg.get("center_lon", 0.0)
    graticule_step = cfg.get("graticule_step", 30.0)
    boundary_kw = cfg.get("boundary_kw")
    graticule_kw = cfg.get("graticule_kw")

    _, _, masked = orthographic_grid(
        lon_arr, lat_arr, data, center_lat=center_lat, center_lon=center_lon
    )
    x_edges, y_edges = orthographic_grid_edges(
        lon_arr, lat_arr, center_lat=center_lat, center_lon=center_lon
    )
    lon_edges_1d = _bin_edges(lon_arr)
    lat_edges_1d = _bin_edges(lat_arr)
    lon_e, lat_e = np.meshgrid(lon_edges_1d, lat_edges_1d)
    edge_visible = _visible_hemisphere(lon_e, lat_e, center_lat, center_lon)
    corner_ok = (
        edge_visible[:-1, :-1]
        & edge_visible[:-1, 1:]
        & edge_visible[1:, :-1]
        & edge_visible[1:, 1:]
    )
    masked = np.where(corner_ok, masked, np.nan)
    if draw_frame:
        boundary = orthographic_boundary()
        graticule = orthographic_graticule(
            center_lat=center_lat, center_lon=center_lon, step=graticule_step
        )
        apply_projection_frame(
            ax,
            boundary_xy=boundary,
            xlim=(-ORTHOGRAPHIC_RADIUS_M, ORTHOGRAPHIC_RADIUS_M),
            ylim=(-ORTHOGRAPHIC_RADIUS_M, ORTHOGRAPHIC_RADIUS_M),
            graticule_lines=graticule,
            boundary_kw=boundary_kw,
            graticule_kw=graticule_kw,
        )
    return x_edges, y_edges, masked

apply_projection_style_mesh(ax, node_x, node_y, triangles, *, style='globe', draw_frame=True, **overrides) #

Reproject an unstructured-mesh triangulation onto a projection preset.

The mesh counterpart to apply_projection_style: reproject the node lon/lat to the projected plane, build a matplotlib.tri.Triangulation on the projected coordinates with the same connectivity, mask any triangle that has a node on the far hemisphere, and -- for "globe" -- draw the boundary + graticule frame on ax. Returns the (masked) triangulation, ready for tripcolor / tricontourf.

Parameters:

Name Type Description Default
ax Axes

Axes to frame (used by the "globe" style).

required
node_x Any

1-D node longitudes (degrees).

required
node_y Any

1-D node latitudes (degrees).

required
triangles Any

(n_tri, 3) node-index connectivity.

required
style str

A PROJECTION_STYLES key ("globe" / "flat").

'globe'
draw_frame bool

Draw the globe boundary + graticule (globe only).

True
**overrides Any

Override the style's center_lat / center_lon / graticule_step.

{}

Returns:

Type Description
Triangulation

matplotlib.tri.Triangulation: The reprojected, far-hemisphere-masked triangulation. "flat" returns the unprojected triangulation.

Raises:

Type Description
KeyError

If style is not a known projection style.

ImportError

If the globe path is used without pyproj (the [tiles] extra).

Source code in src/cleopatra/basemap/projection.py
def apply_projection_style_mesh(
    ax: Axes,
    node_x: Any,
    node_y: Any,
    triangles: Any,
    *,
    style: str = "globe",
    draw_frame: bool = True,
    **overrides: Any,
) -> Triangulation:
    """Reproject an unstructured-mesh triangulation onto a projection preset.

    The mesh counterpart to `apply_projection_style`: reproject the node lon/lat
    to the projected plane, build a `matplotlib.tri.Triangulation` on the
    projected coordinates with the same connectivity, mask any triangle that has
    a node on the far hemisphere, and -- for `"globe"` -- draw the boundary +
    graticule frame on `ax`. Returns the (masked) triangulation, ready for
    `tripcolor` / `tricontourf`.

    Args:
        ax: Axes to frame (used by the `"globe"` style).
        node_x: 1-D node longitudes (degrees).
        node_y: 1-D node latitudes (degrees).
        triangles: `(n_tri, 3)` node-index connectivity.
        style: A `PROJECTION_STYLES` key (`"globe"` / `"flat"`).
        draw_frame: Draw the globe boundary + graticule (globe only).
        **overrides: Override the style's `center_lat` / `center_lon` /
            `graticule_step`.

    Returns:
        matplotlib.tri.Triangulation: The reprojected, far-hemisphere-masked
            triangulation. `"flat"` returns the unprojected triangulation.

    Raises:
        KeyError: If `style` is not a known projection style.
        ImportError: If the globe path is used without `pyproj` (the `[tiles]`
            extra).
    """
    if style not in PROJECTION_STYLES:
        raise KeyError(
            f"Unknown projection style {style!r}; available: {sorted(PROJECTION_STYLES)}"
        )
    params = {**PROJECTION_STYLES[style], **overrides}
    node_x = np.asarray(node_x, dtype=float)
    node_y = np.asarray(node_y, dtype=float)
    triangles = np.asarray(triangles)
    if style == "flat":
        return Triangulation(node_x, node_y, triangles)
    _require_pyproj("Orthographic ('globe') mesh projection")
    center_lat = params.get("center_lat", 90.0)
    center_lon = params.get("center_lon", 0.0)
    x_proj, y_proj = orthographic_points(node_x, node_y, center_lat, center_lon)
    tri = Triangulation(x_proj, y_proj, triangles)
    visible = _visible_hemisphere(node_x, node_y, center_lat, center_lon)
    if triangles.size:
        tri.set_mask(~visible[triangles].all(axis=1))
    if draw_frame:
        radius = ORTHOGRAPHIC_RADIUS_M
        apply_projection_frame(
            ax,
            boundary_xy=orthographic_boundary(radius=radius),
            xlim=(-radius, radius),
            ylim=(-radius, radius),
            graticule_lines=orthographic_graticule(
                center_lat, center_lon, params.get("graticule_step", 30.0)
            ),
            clip_artists=False,
        )
    return tri

orthographic_boundary(n=200, radius=ORTHOGRAPHIC_RADIUS_M) #

Return the orthographic globe's boundary circle.

The orthographic projection's visible-hemisphere boundary is always a circle of the projection's radius centred at the origin, independent of which point the globe is centred on -- no pyproj call is needed. Pass the result as apply_projection_frame's boundary_xy.

Parameters:

Name Type Description Default
n int

Number of vertices around the circle.

200
radius float

Circle radius in projected-CRS units (metres). Defaults to ORTHOGRAPHIC_RADIUS_M, the same radius orthographic_grid projects with -- pass a matching value to both if overridden.

ORTHOGRAPHIC_RADIUS_M

Returns:

Type Description
ndarray

np.ndarray: (n, 2) array of boundary vertices.

Examples:

  • The boundary is a circle of the given radius, centred at the origin:
    >>> import numpy as np
    >>> from cleopatra.basemap.projection import orthographic_boundary
    >>> boundary = orthographic_boundary(n=100, radius=1.0)
    >>> boundary.shape
    (100, 2)
    >>> np.allclose(np.hypot(boundary[:, 0], boundary[:, 1]), 1.0)
    True
    
See Also

orthographic_grid: The projected data this boundary frames. apply_projection_frame: Renders the boundary onto an axes.

Source code in src/cleopatra/basemap/projection.py
def orthographic_boundary(
    n: int = 200, radius: float = ORTHOGRAPHIC_RADIUS_M
) -> np.ndarray:
    """Return the orthographic globe's boundary circle.

    The orthographic projection's visible-hemisphere boundary is always a
    circle of the projection's radius centred at the origin, independent of
    which point the globe is centred on -- no pyproj call is needed. Pass
    the result as `apply_projection_frame`'s `boundary_xy`.

    Args:
        n: Number of vertices around the circle.
        radius: Circle radius in projected-CRS units (metres). Defaults to
            `ORTHOGRAPHIC_RADIUS_M`, the same radius `orthographic_grid`
            projects with -- pass a matching value to both if overridden.

    Returns:
        np.ndarray: `(n, 2)` array of boundary vertices.

    Examples:
        - The boundary is a circle of the given radius, centred at the origin:
            ```python
            >>> import numpy as np
            >>> from cleopatra.basemap.projection import orthographic_boundary
            >>> boundary = orthographic_boundary(n=100, radius=1.0)
            >>> boundary.shape
            (100, 2)
            >>> np.allclose(np.hypot(boundary[:, 0], boundary[:, 1]), 1.0)
            True

            ```

    See Also:
        orthographic_grid: The projected data this boundary frames.
        apply_projection_frame: Renders the boundary onto an axes.
    """
    theta = np.linspace(0.0, 2.0 * np.pi, n)
    return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)])

orthographic_graticule(center_lat=90.0, center_lon=0.0, step=30.0, densify=200) #

Build graticule (meridian/parallel) polylines for an orthographic view.

Generates meridian (constant-longitude) and parallel (constant-latitude) lines spaced step degrees apart, reprojects them with the same centre as orthographic_grid, and splits each at the visible-hemisphere edge so only the visible portion of each line is returned. Pass the result as apply_projection_frame's graticule_lines.

Parameters:

Name Type Description Default
center_lat float

Latitude the globe is centred on. Must match the value passed to orthographic_grid for the graticule to align with the data.

90.0
center_lon float

Longitude the globe is centred on. Must match orthographic_grid.

0.0
step float

Degree spacing between meridians/parallels. Must be positive.

30.0
densify int

Number of points each line is sampled at before reprojecting -- higher values give smoother curves near the visible-hemisphere edge.

200

Returns:

Type Description
list[ndarray]

list[np.ndarray]: One (m, 2) array per visible line segment (lines

list[ndarray]

crossing the hemisphere edge are split into separate segments).

Raises:

Type Description
ImportError

If pyproj (the [tiles] extra) is not installed.

ValueError

If step is not a positive, finite number.

Examples:

  • A 90-degree step gives a small set of meridians/parallels, each a (densify, 2) or shorter (edge-clipped) segment:
    >>> from cleopatra.basemap.projection import orthographic_graticule
    >>> lines = orthographic_graticule(step=90.0, densify=50)
    >>> len(lines) > 0
    True
    >>> all(line.shape[1] == 2 for line in lines)
    True
    
  • A non-positive step raises ValueError:
    >>> from cleopatra.basemap.projection import orthographic_graticule
    >>> orthographic_graticule(step=0)
    Traceback (most recent call last):
        ...
    ValueError: step must be a positive, finite number, got 0
    
See Also

orthographic_grid: The projected data this graticule frames. orthographic_boundary: The matching globe outline.

Source code in src/cleopatra/basemap/projection.py
def orthographic_graticule(
    center_lat: float = 90.0,
    center_lon: float = 0.0,
    step: float = 30.0,
    densify: int = 200,
) -> list[np.ndarray]:
    """Build graticule (meridian/parallel) polylines for an orthographic view.

    Generates meridian (constant-longitude) and parallel (constant-latitude)
    lines spaced `step` degrees apart, reprojects them with the same centre
    as `orthographic_grid`, and splits each at the visible-hemisphere edge so
    only the visible portion of each line is returned. Pass the result as
    `apply_projection_frame`'s `graticule_lines`.

    Args:
        center_lat: Latitude the globe is centred on. Must match the value
            passed to `orthographic_grid` for the graticule to align with
            the data.
        center_lon: Longitude the globe is centred on. Must match
            `orthographic_grid`.
        step: Degree spacing between meridians/parallels. Must be positive.
        densify: Number of points each line is sampled at before
            reprojecting -- higher values give smoother curves near the
            visible-hemisphere edge.

    Returns:
        list[np.ndarray]: One `(m, 2)` array per visible line segment (lines
        crossing the hemisphere edge are split into separate segments).

    Raises:
        ImportError: If pyproj (the `[tiles]` extra) is not installed.
        ValueError: If `step` is not a positive, finite number.

    Examples:
        - A 90-degree step gives a small set of meridians/parallels, each a
          `(densify, 2)` or shorter (edge-clipped) segment:
            ```python
            >>> from cleopatra.basemap.projection import orthographic_graticule
            >>> lines = orthographic_graticule(step=90.0, densify=50)
            >>> len(lines) > 0
            True
            >>> all(line.shape[1] == 2 for line in lines)
            True

            ```
        - A non-positive step raises `ValueError`:
            ```python
            >>> from cleopatra.basemap.projection import orthographic_graticule
            >>> orthographic_graticule(step=0)
            Traceback (most recent call last):
                ...
            ValueError: step must be a positive, finite number, got 0

            ```

    See Also:
        orthographic_grid: The projected data this graticule frames.
        orthographic_boundary: The matching globe outline.
    """
    if not math.isfinite(step) or step <= 0:
        raise ValueError(f"step must be a positive, finite number, got {step}")
    _require_pyproj("Orthographic ('globe') graticule")
    from pyproj import Transformer

    transformer = Transformer.from_crs(
        "EPSG:4326", _ortho_proj4(center_lat, center_lon), always_xy=True
    )

    lines_lonlat = []
    lat_dense = np.linspace(-90.0, 90.0, densify)
    for lon0 in np.arange(-180.0, 180.0, step):
        lines_lonlat.append(np.column_stack([np.full_like(lat_dense, lon0), lat_dense]))
    lon_dense = np.linspace(-180.0, 180.0, densify)
    for lat0 in np.arange(-90.0 + step, 90.0, step):
        lines_lonlat.append(np.column_stack([lon_dense, np.full_like(lon_dense, lat0)]))

    segments: list[np.ndarray] = []
    for line in lines_lonlat:
        lon_l, lat_l = line[:, 0], line[:, 1]
        x_l, y_l = transformer.transform(lon_l, lat_l)
        visible = _visible_hemisphere(lon_l, lat_l, center_lat, center_lon)
        xy = np.column_stack([x_l, y_l])
        segments.extend(_split_visible_runs(xy, visible))
    return segments

orthographic_grid(lon, lat, data, center_lat=90.0, center_lon=0.0) #

Reproject a lon/lat grid onto an orthographic ('globe') view.

Projects every grid point via pyproj onto a sphere viewed from directly above (center_lat, center_lon), and masks out the far (non-visible) hemisphere -- the orthographic projection formula is defined everywhere but is only meaningful for the visible half, so points beyond it are set to NaN in the returned data rather than silently folded onto the visible disk. Pair the result with alpha_scaled_mesh (cleopatra.styling.colors), which -- unlike alpha_scaled_image -- can render this kind of curvilinear (non-rectangular) grid.

Parameters:

Name Type Description Default
lon Any

Longitudes in degrees, either a 1D vector (paired with a 1D lat to build a regular grid via np.meshgrid) or a 2D array already matching data's shape.

required
lat Any

Latitudes in degrees, same convention as lon.

required
data Any

2D array of values to reproject alongside the grid.

required
center_lat float

Latitude the globe is centred on (the "camera" points at this point). Defaults to 90.0 (the North Pole), matching the CAMS Arctic-centred style.

90.0
center_lon float

Longitude the globe is centred on. Defaults to 0.0.

0.0

Returns:

Name Type Description
tuple ndarray

(x, y, masked_data) -- projected x/y coordinates (metres,

ndarray

same shape as data) and a copy of data with the far hemisphere

ndarray

set to NaN. x/y are always finite: the orthographic formula

tuple[ndarray, ndarray, ndarray]

diverges at the antipodal point, so any non-visible or non-finite

tuple[ndarray, ndarray, ndarray]

coordinate is replaced with a 0.0 placeholder (safe because those

tuple[ndarray, ndarray, ndarray]

cells carry no data and are never rendered), keeping the grid a

tuple[ndarray, ndarray, ndarray]

valid pcolormesh/imshow input.

Raises:

Type Description
ImportError

If pyproj (the [tiles] extra) is not installed.

ValueError

If lon/lat/data shapes are incompatible.

Examples:

  • Reproject a tiny 2x2 grid centred on the North Pole; the row at latitude -90 (South Pole) is masked out as not visible:
    >>> import numpy as np
    >>> from cleopatra.basemap.projection import orthographic_grid
    >>> lon = np.array([0.0, 90.0])
    >>> lat = np.array([90.0, -90.0])
    >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
    >>> x, y, masked = orthographic_grid(lon, lat, data)
    >>> masked[0]  # latitude 90: visible, values kept
    array([1., 2.])
    >>> np.all(np.isnan(masked[1]))  # latitude -90: not visible
    np.True_
    
See Also

orthographic_boundary: The matching globe outline for this centre. orthographic_graticule: Matching meridian/parallel lines. cleopatra.styling.colors.alpha_scaled_mesh: Renders the returned grid.

Source code in src/cleopatra/basemap/projection.py
def orthographic_grid(
    lon: Any,
    lat: Any,
    data: Any,
    center_lat: float = 90.0,
    center_lon: float = 0.0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Reproject a lon/lat grid onto an orthographic ('globe') view.

    Projects every grid point via pyproj onto a sphere viewed from directly
    above `(center_lat, center_lon)`, and masks out the far (non-visible)
    hemisphere -- the orthographic projection formula is defined everywhere
    but is only meaningful for the visible half, so points beyond it are set
    to NaN in the returned data rather than silently folded onto the visible
    disk. Pair the result with `alpha_scaled_mesh` (`cleopatra.styling.colors`),
    which -- unlike `alpha_scaled_image` -- can render this kind of
    curvilinear (non-rectangular) grid.

    Args:
        lon: Longitudes in degrees, either a 1D vector (paired with a 1D
            `lat` to build a regular grid via `np.meshgrid`) or a 2D array
            already matching `data`'s shape.
        lat: Latitudes in degrees, same convention as `lon`.
        data: 2D array of values to reproject alongside the grid.
        center_lat: Latitude the globe is centred on (the "camera" points at
            this point). Defaults to `90.0` (the North Pole), matching the
            CAMS Arctic-centred style.
        center_lon: Longitude the globe is centred on. Defaults to `0.0`.

    Returns:
        tuple: `(x, y, masked_data)` -- projected x/y coordinates (metres,
        same shape as `data`) and a copy of `data` with the far hemisphere
        set to NaN. `x`/`y` are always finite: the orthographic formula
        diverges at the antipodal point, so any non-visible or non-finite
        coordinate is replaced with a `0.0` placeholder (safe because those
        cells carry no data and are never rendered), keeping the grid a
        valid `pcolormesh`/`imshow` input.

    Raises:
        ImportError: If pyproj (the `[tiles]` extra) is not installed.
        ValueError: If `lon`/`lat`/`data` shapes are incompatible.

    Examples:
        - Reproject a tiny 2x2 grid centred on the North Pole; the row at
          latitude -90 (South Pole) is masked out as not visible:
            ```python
            >>> import numpy as np
            >>> from cleopatra.basemap.projection import orthographic_grid
            >>> lon = np.array([0.0, 90.0])
            >>> lat = np.array([90.0, -90.0])
            >>> data = np.array([[1.0, 2.0], [3.0, 4.0]])
            >>> x, y, masked = orthographic_grid(lon, lat, data)
            >>> masked[0]  # latitude 90: visible, values kept
            array([1., 2.])
            >>> np.all(np.isnan(masked[1]))  # latitude -90: not visible
            np.True_

            ```

    See Also:
        orthographic_boundary: The matching globe outline for this centre.
        orthographic_graticule: Matching meridian/parallel lines.
        cleopatra.styling.colors.alpha_scaled_mesh: Renders the returned grid.
    """
    _require_pyproj("Orthographic ('globe') projection")
    from pyproj import Transformer

    lon_arr = np.asarray(lon, dtype=float)
    lat_arr = np.asarray(lat, dtype=float)
    data_arr = np.asarray(data, dtype=float)
    if lon_arr.ndim == 1 and lat_arr.ndim == 1 and data_arr.ndim == 2:
        lon_arr, lat_arr = np.meshgrid(lon_arr, lat_arr)
    if lon_arr.shape != data_arr.shape or lat_arr.shape != data_arr.shape:
        raise ValueError(
            "lon/lat/data shapes must match (after meshgrid for 1D lon/lat), "
            f"got lon={lon_arr.shape}, lat={lat_arr.shape}, data={data_arr.shape}"
        )

    transformer = Transformer.from_crs(
        "EPSG:4326", _ortho_proj4(center_lat, center_lon), always_xy=True
    )
    x_raw, y_raw = transformer.transform(lon_arr, lat_arr)
    x = np.asarray(x_raw, dtype=float)
    y = np.asarray(y_raw, dtype=float)
    visible = _visible_hemisphere(lon_arr, lat_arr, center_lat, center_lon)
    masked_data = np.where(visible, data_arr, np.nan)
    coord_ok = visible & np.isfinite(x) & np.isfinite(y)
    x = np.where(coord_ok, x, 0.0)
    y = np.where(coord_ok, y, 0.0)
    return x, y, masked_data

orthographic_grid_edges(lon, lat, center_lat=90.0, center_lon=0.0) #

Reproject 1D lon/lat cell-centre vectors to orthographic cell-edge coordinates.

Companion to orthographic_grid: that function reprojects data at cell centres, matching pcolormesh's shading="auto"/"nearest" convention -- but the orthographic projection's extreme local distortion (longitude lines compress drastically near the projection centre) makes matplotlib's automatic centre-to-edge inference unreliable there: most cells can render as degenerate slivers, silently dropping most of the grid. This function instead reprojects the cell edges, for use with shading="flat" (matplotlib draws exactly the given quads, inferring nothing) -- the reliable choice for this projection.

Parameters:

Name Type Description Default
lon Any

1D vector of cell-centre longitudes, degrees.

required
lat Any

1D vector of cell-centre latitudes, degrees.

required
center_lat float

Latitude the globe is centred on. Must match the value passed to orthographic_grid for the data these edges frame.

90.0
center_lon float

Longitude the globe is centred on. Must match orthographic_grid.

0.0

Returns:

Name Type Description
tuple ndarray

(x_edges, y_edges), each shaped (len(lat) + 1, len(lon) + 1)

ndarray

-- one more per axis than a (len(lat), len(lon)) data array, ready

tuple[ndarray, ndarray]

for ax.pcolormesh(x_edges, y_edges, data, shading="flat").

Raises:

Type Description
ImportError

If pyproj (the [tiles] extra) is not installed.

Warning

The orthographic projection is only finite on the visible (near) hemisphere -- an edge point beyond it has no real coordinate, so it is placed at a finite placeholder (not a meaningful position). A data cell whose centre is visible can still have a corner past the horizon; drawing it anyway pulls that corner toward the placeholder, producing a wrongly-shaped quad. Before drawing, drop (NaN) any cell for which not all four corners are visible -- apply_projection_style does this automatically; call it instead of this function directly unless you are prepared to replicate that check.

Examples:

  • Edge arrays are one larger per axis than the centre vectors:
    >>> import numpy as np
    >>> from cleopatra.basemap.projection import orthographic_grid_edges
    >>> lon = np.linspace(-180, 180, 8)
    >>> lat = np.linspace(-90, 90, 4)
    >>> x_edges, y_edges = orthographic_grid_edges(lon, lat)
    >>> x_edges.shape, y_edges.shape
    ((5, 9), (5, 9))
    
See Also

orthographic_grid: The matching cell-centre reprojection for data. cleopatra.styling.colors.alpha_scaled_mesh: Renders with shading="flat".

Source code in src/cleopatra/basemap/projection.py
def orthographic_grid_edges(
    lon: Any,
    lat: Any,
    center_lat: float = 90.0,
    center_lon: float = 0.0,
) -> tuple[np.ndarray, np.ndarray]:
    """Reproject 1D lon/lat cell-centre vectors to orthographic cell-*edge* coordinates.

    Companion to `orthographic_grid`: that function reprojects data at cell
    *centres*, matching `pcolormesh`'s `shading="auto"`/`"nearest"`
    convention -- but the orthographic projection's extreme local distortion
    (longitude lines compress drastically near the projection centre) makes
    matplotlib's automatic centre-to-edge inference unreliable there: most
    cells can render as degenerate slivers, silently dropping most of the
    grid. This function instead reprojects the cell **edges**, for use with
    `shading="flat"` (matplotlib draws exactly the given quads, inferring
    nothing) -- the reliable choice for this projection.

    Args:
        lon: 1D vector of cell-centre longitudes, degrees.
        lat: 1D vector of cell-centre latitudes, degrees.
        center_lat: Latitude the globe is centred on. Must match the value
            passed to `orthographic_grid` for the data these edges frame.
        center_lon: Longitude the globe is centred on. Must match
            `orthographic_grid`.

    Returns:
        tuple: `(x_edges, y_edges)`, each shaped `(len(lat) + 1, len(lon) + 1)`
        -- one more per axis than a `(len(lat), len(lon))` data array, ready
        for `ax.pcolormesh(x_edges, y_edges, data, shading="flat")`.

    Raises:
        ImportError: If pyproj (the `[tiles]` extra) is not installed.

    Warning:
        The orthographic projection is only finite on the visible (near)
        hemisphere -- an edge point beyond it has no real coordinate, so it
        is placed at a finite placeholder (not a meaningful position). A data
        cell whose *centre* is visible can still have a corner past the
        horizon; drawing it anyway pulls that corner toward the placeholder,
        producing a wrongly-shaped quad. Before drawing, drop (`NaN`) any
        cell for which not all four corners are visible -- `apply_projection_style`
        does this automatically; call it instead of this function directly
        unless you are prepared to replicate that check.

    Examples:
        - Edge arrays are one larger per axis than the centre vectors:
            ```python
            >>> import numpy as np
            >>> from cleopatra.basemap.projection import orthographic_grid_edges
            >>> lon = np.linspace(-180, 180, 8)
            >>> lat = np.linspace(-90, 90, 4)
            >>> x_edges, y_edges = orthographic_grid_edges(lon, lat)
            >>> x_edges.shape, y_edges.shape
            ((5, 9), (5, 9))

            ```

    See Also:
        orthographic_grid: The matching cell-centre reprojection for `data`.
        cleopatra.styling.colors.alpha_scaled_mesh: Renders with `shading="flat"`.
    """
    lon_edges_1d = _bin_edges(np.asarray(lon, dtype=float))
    lat_edges_1d = _bin_edges(np.asarray(lat, dtype=float))
    lon_e, lat_e = np.meshgrid(lon_edges_1d, lat_edges_1d)
    dummy = np.zeros_like(lon_e)
    x_edges, y_edges, _ = orthographic_grid(
        lon_e, lat_e, dummy, center_lat=center_lat, center_lon=center_lon
    )
    return x_edges, y_edges

orthographic_points(lon, lat, center_lat=90.0, center_lon=0.0) #

Reproject scattered lon/lat points onto an orthographic ('globe') view.

The point counterpart to orthographic_grid: use this for discrete locations -- e.g. city markers for cleopatra.basemap.geo.add_point_labels -- rather than a raster grid. A globe's axes are scaled in projected metres (ORTHOGRAPHIC_RADIUS_M), not degrees, so plotting raw lon/lat values directly on a globe axes collapses every point toward the origin; reproject with this function first.

Parameters:

Name Type Description Default
lon Any

Longitudes in degrees, scalar or 1D array.

required
lat Any

Latitudes in degrees, scalar or 1D array, paired with lon.

required
center_lat float

Latitude the globe is centred on. Must match the value passed to orthographic_grid/apply_projection_style for the points to align with the data.

90.0
center_lon float

Longitude the globe is centred on. Must match orthographic_grid/apply_projection_style.

0.0

Returns:

Name Type Description
tuple ndarray

(x, y), the same shape as lon/lat (broadcast to at

ndarray

least 1D). A point on the far (non-visible) hemisphere is NaN in

tuple[ndarray, ndarray]

both x and y -- filter those out before plotting.

Raises:

Type Description
ImportError

If pyproj (the [tiles] extra) is not installed.

Examples:

  • Reproject two cities visible from a North-Pole-centred view; a point on the far side comes back NaN:
    >>> import numpy as np
    >>> from cleopatra.basemap.projection import orthographic_points
    >>> lon = np.array([-21.9, 0.0])
    >>> lat = np.array([64.1, -80.0])
    >>> x, y = orthographic_points(lon, lat, center_lat=90.0, center_lon=0.0)
    >>> np.isnan(x[0])  # Reykjavik: visible
    np.False_
    >>> np.isnan(x[1])  # near the South Pole: not visible
    np.True_
    
  • A single scalar point round-trips as a 1-element array:
    >>> from cleopatra.basemap.projection import orthographic_points
    >>> x, y = orthographic_points(0.0, 90.0)
    >>> round(float(x[0]), 6), round(float(y[0]), 6)
    (0.0, -0.0)
    
See Also

orthographic_grid: The raster-grid counterpart. cleopatra.basemap.geo.add_point_labels: Renders the reprojected points.

Source code in src/cleopatra/basemap/projection.py
def orthographic_points(
    lon: Any,
    lat: Any,
    center_lat: float = 90.0,
    center_lon: float = 0.0,
) -> tuple[np.ndarray, np.ndarray]:
    """Reproject scattered lon/lat points onto an orthographic ('globe') view.

    The point counterpart to `orthographic_grid`: use this for discrete
    locations -- e.g. city markers for `cleopatra.basemap.geo.add_point_labels` --
    rather than a raster grid. A globe's axes are scaled in projected
    metres (`ORTHOGRAPHIC_RADIUS_M`), not degrees, so plotting raw lon/lat
    values directly on a globe axes collapses every point toward the origin;
    reproject with this function first.

    Args:
        lon: Longitudes in degrees, scalar or 1D array.
        lat: Latitudes in degrees, scalar or 1D array, paired with `lon`.
        center_lat: Latitude the globe is centred on. Must match the value
            passed to `orthographic_grid`/`apply_projection_style` for the
            points to align with the data.
        center_lon: Longitude the globe is centred on. Must match
            `orthographic_grid`/`apply_projection_style`.

    Returns:
        tuple: `(x, y)`, the same shape as `lon`/`lat` (broadcast to at
        least 1D). A point on the far (non-visible) hemisphere is `NaN` in
        both `x` and `y` -- filter those out before plotting.

    Raises:
        ImportError: If pyproj (the `[tiles]` extra) is not installed.

    Examples:
        - Reproject two cities visible from a North-Pole-centred view; a
          point on the far side comes back `NaN`:
            ```python
            >>> import numpy as np
            >>> from cleopatra.basemap.projection import orthographic_points
            >>> lon = np.array([-21.9, 0.0])
            >>> lat = np.array([64.1, -80.0])
            >>> x, y = orthographic_points(lon, lat, center_lat=90.0, center_lon=0.0)
            >>> np.isnan(x[0])  # Reykjavik: visible
            np.False_
            >>> np.isnan(x[1])  # near the South Pole: not visible
            np.True_

            ```
        - A single scalar point round-trips as a 1-element array:
            ```python
            >>> from cleopatra.basemap.projection import orthographic_points
            >>> x, y = orthographic_points(0.0, 90.0)
            >>> round(float(x[0]), 6), round(float(y[0]), 6)
            (0.0, -0.0)

            ```

    See Also:
        orthographic_grid: The raster-grid counterpart.
        cleopatra.basemap.geo.add_point_labels: Renders the reprojected points.
    """
    _require_pyproj("Orthographic ('globe') point reprojection")
    from pyproj import Transformer

    lon_arr = np.atleast_1d(np.asarray(lon, dtype=float))
    lat_arr = np.atleast_1d(np.asarray(lat, dtype=float))
    transformer = Transformer.from_crs(
        "EPSG:4326", _ortho_proj4(center_lat, center_lon), always_xy=True
    )
    x_raw, y_raw = transformer.transform(lon_arr, lat_arr)
    x = np.asarray(x_raw, dtype=float)
    y = np.asarray(y_raw, dtype=float)
    visible = _visible_hemisphere(lon_arr, lat_arr, center_lat, center_lon)
    x = np.where(visible & np.isfinite(x), x, np.nan)
    y = np.where(visible & np.isfinite(y), y, np.nan)
    return x, y

projection_draws_frame(style) #

Whether a projection style draws a frozen boundary/graticule frame.

"globe" draws the frame and freezes the axes limits/aspect and hides the axis; "flat" (identity) and no projection do not touch the axes. Glyphs use this to decide whether the current render installs a frame (so a later flat render must undo it), treating "flat" and None alike as flat views.

Parameters:

Name Type Description Default
style Any

A projection style name, or a falsy value for no projection.

required

Returns:

Name Type Description
bool bool

True only for a framed style (currently "globe").

Source code in src/cleopatra/basemap/projection.py
def projection_draws_frame(style: Any) -> bool:
    """Whether a projection `style` draws a frozen boundary/graticule frame.

    `"globe"` draws the frame and freezes the axes limits/aspect and hides the
    axis; `"flat"` (identity) and no projection do not touch the axes. Glyphs use
    this to decide whether the current render installs a frame (so a later flat
    render must undo it), treating `"flat"` and `None` alike as flat views.

    Args:
        style: A projection style name, or a falsy value for no projection.

    Returns:
        bool: `True` only for a framed style (currently `"globe"`).
    """
    return style in _FRAMED_PROJECTION_STYLES