Skip to content

Instantly share code, notes, and snippets.

@bennyistanto
Created November 17, 2025 13:22
Show Gist options
  • Select an option

  • Save bennyistanto/1daa2a1c336fda19ce87c04d2a55b4c4 to your computer and use it in GitHub Desktop.

Select an option

Save bennyistanto/1daa2a1c336fda19ce87c04d2a55b4c4 to your computer and use it in GitHub Desktop.

Spiral Earth — A Nautilus Projection of the World


day19

This gist contains a purely conceptual map projection used for
#30DayMapChallenge 2025 — Day 19 (Projections).

The idea is to take standard geographic coordinates (longitude, latitude) and wrap the Earth into a spiral / nautilus shell:

  • South Pole → spiral core
  • North Pole → outer ring
  • Parallels become spiral arms
  • Meridians get bent and twisted as they move outward

Nothing is preserved (not area, not distance, not direction) — this is deliberately a “projection as art / thought experiment” rather than a useful cartographic projection.


Projection definition

Let:

  • Longitude $( \lambda \in [-180^\circ, 180^\circ] )$
  • Latitude $( \varphi \in [-90^\circ, 90^\circ] )$

We first normalize latitude to a unit interval:

$$ u = \mathrm{clamp}\left( \frac{\varphi + 90^\circ}{180^\circ}, 0, 1 \right) $$

This gives:

  • $( u = 0 )$ at the South Pole
  • $( u = 0.5 )$ at the Equator
  • $( u = 1 )$ at the North Pole

Radius is then defined as:

$$ r = R_{\text{scale}} \cdot u^{\alpha} $$

Where:

  • R_SCALE = overall radius of the map
  • EXPONENT = α controls how quickly radius grows with latitude
    • $( \alpha < 1 )$: more space for mid / high latitudes
    • $( \alpha > 1 )$: more emphasis near the South Pole

Angle has two parts:

  1. A standard azimuthal term from longitude:

$$ \theta_\lambda = \mathrm{rad}(\lambda) + \pi $$

  1. A latitude-dependent twist that makes parallels spiral instead of staying circular:

$$ \theta = \theta_\lambda + 2 \pi \cdot T \cdot u $$

Where:

  • SPIRAL_TURNS = T is the number of extra rotations from South → North
    • T = 0 → standard azimuthal-like mapping
    • T > 0 → nautilus-style spiral

Finally, spiral-plane coordinates are:

$$ x = r \cos \theta, \qquad y = r \sin \theta $$

This mapping is implemented in lonlat_to_spiral():

def lonlat_to_spiral(lon, lat, r_scale=R_SCALE, exponent=EXPONENT, turns=SPIRAL_TURNS):
    lon = np.asarray(lon)
    lat = np.asarray(lat)

    # 1) normalize latitude
    u = (lat + 90.0) / 180.0
    u = np.clip(u, 0.0, 1.0)

    # 2) radius
    r = (u ** exponent) * r_scale

    # 3) base angle from longitude
    theta_lon = np.deg2rad(lon) + np.pi

    # 4) extra spiral twist
    theta = theta_lon + 2 * np.pi * turns * u

    # 5) cartesian coords
    x = r * np.cos(theta)
    y = r * np.sin(theta)
    return x, y

Implementation strategy (Python)

1. Data: Natural Earth via cartopy.feature

Instead of bundling shapefiles or calling shapereader directly, the script uses cartopy.feature.NaturalEarthFeature to fetch and cache the Natural Earth admin-0 countries dataset (1:110m). The geometries are then wrapped in a GeoDataFrame:

from cartopy import feature as cfeature

ne_countries = cfeature.NaturalEarthFeature(
    category="cultural",
    name="admin_0_countries",
    scale="110m",
)

geoms = list(ne_countries.geometries())
world = gpd.GeoDataFrame({"geometry": geoms}, crs="EPSG:4326")

2. Latitude-based colouring

To make the spiral more intuitive, each country is coloured by the latitude of its centroid:

  1. Reproject to a projected CRS (World Cylindrical Equal Area)
  2. Compute centroids in projected space
  3. Transform centroids back to EPSG:4326 and read their latitude
world_proj = world.to_crs("ESRI:54034")
cent_proj = world_proj.geometry.centroid
cent_ll = gpd.GeoSeries(cent_proj, crs=world_proj.crs).to_crs("EPSG:4326")
world["lat_centroid"] = cent_ll.y

In the plot, lat_centroid is mapped to a coolwarm colour scale and a colorbar is added as a legend (Latitude (°)).

3. Applying the custom projection to geometries

shapely.ops.transform is used to wrap the lonlat_to_spiral() function and apply it to each geometry:

from shapely.ops import transform

def spiral_geometry(geom):
    def _f(x, y, z=None):
        xs, ys = lonlat_to_spiral(x, y)
        return xs, ys
    return transform(_f, geom)

world_spiral = world.copy()
world_spiral["geometry"] = world_spiral["geometry"].apply(spiral_geometry)

Once projected, the geometries plot like any other GeoPandas GeoDataFrame.

4. Drawing the graticule

A simple synthetic graticule (parallels and meridians) is constructed in geographic coordinates, then passed through the same transformation:

def build_graticule():
    grat_lines = []

    # Parallels
    for lat in range(-80, 81, 20):
        lons = np.linspace(-180, 180, 361)
        lats = np.full_like(lons, lat, dtype=float)
        x, y = lonlat_to_spiral(lons, lats)
        grat_lines.append((x, y))

    # Meridians
    for lon in range(-180, 181, 30):
        lats = np.linspace(-90, 90, 361)
        lons = np.full_like(lats, lon, dtype=float)
        x, y = lonlat_to_spiral(lons, lats)
        grat_lines.append((x, y))

    return grat_lines

Because of the spiral angle term, the parallels curl into spiral arms, and the meridians bend and twist as they move outward.

5. Plotting

The final plot uses:

  • Dark background (fig.patch.set_facecolor("black"))
  • world_spiral.plot(...) to draw the countries
  • Light, semi-transparent graticule lines
  • A colourbar for latitude
  • Text labels for South Pole (core) and North Pole (outer ring)
  • Title, subtitle, and bottom attribution

The output is saved as day19_projections.png.


Dependencies & running

Required Python packages:

  • numpy
  • matplotlib
  • geopandas
  • shapely
  • cartopy

Run:

python day19_spiral_earth.py

(or execute the cell if you drop it into a Jupyter notebook).

The script will:

  1. Fetch Natural Earth via cartopy.feature.NaturalEarthFeature (first run only)
  2. Apply the Spiral Earth projection
  3. Produce day19_projections.png in the working directory.

Playing with the parameters

At the top of the script:

R_SCALE = 10.0
EXPONENT = 0.7
SPIRAL_TURNS = 1.25

You can experiment with:

  • SPIRAL_TURNS — tighter or looser spiral coils (e.g. 1.0, 2.0, 3.0)
  • EXPONENT — redistribute radial space between low and high latitudes
  • Colormap — swap "coolwarm" for another Matplotlib colormap

These changes let you keep the same basic idea (latitude → radius, spiral angle) while exploring different visual behaviors of the projection.

import numpy as np
import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
from shapely.ops import transform
from cartopy import feature as cfeature
# -------------------------------------------------------------------
# Config
# -------------------------------------------------------------------
MAIN_TITLE = "SPIRAL EARTH — A Nautilus Projection of the World"
SUBTITLE = "Longitude as angle, latitude as radius: a conceptual, non-equal-area projection"
ATTRIBUTION = "#30DayMapChallenge — Day 19 (Projections) | @bennyistanto"
FILENAME = "day19_projections.png"
R_SCALE = 10.0 # overall radius of the spiral (plot units)
EXPONENT = 0.7 # <1 spreads out tropics, >1 compresses them
SPIRAL_TURNS = 1.25 # how many extra rotations from South → North (try 1–3)
# -------------------------------------------------------------------
# 1. Core projection: lon/lat → spiral (x, y)
# -------------------------------------------------------------------
def lonlat_to_spiral(lon, lat, r_scale=R_SCALE, exponent=EXPONENT, turns=SPIRAL_TURNS):
"""
Spiral Earth projection.
- Latitude controls radius (South Pole at center, North Pole outer ring).
- Longitude AND latitude together control angle, so lines of latitude
become spirals instead of circles.
Parameters
----------
lon : array-like
Longitude in degrees (-180..180).
lat : array-like
Latitude in degrees (-90..90).
r_scale : float
Overall radius scaling factor.
exponent : float
Controls how latitude maps to radius: r ~ ((lat+90)/180)**exponent
turns : float
Extra spiral rotations from South → North (0 = azimuthal).
Returns
-------
x, y : arrays
2D planar coordinates in spiral space.
"""
lon = np.asarray(lon)
lat = np.asarray(lat)
# Normalize latitude from [-90, 90] → [0, 1]
u = (lat + 90.0) / 180.0
u = np.clip(u, 0.0, 1.0)
# Radius grows with latitude (non-linear to emphasise tropics)
r = (u ** exponent) * r_scale
# Base angle from longitude
theta_lon = np.deg2rad(lon) + np.pi
# Add a latitude-dependent twist: South=0 extra, North=turns * 2π extra
theta = theta_lon + 2 * np.pi * turns * u
x = r * np.cos(theta)
y = r * np.sin(theta)
return x, y
def spiral_geometry(geom):
"""Apply the Spiral Earth projection to a shapely geometry."""
def _f(x, y, z=None):
xs, ys = lonlat_to_spiral(x, y)
return xs, ys
return transform(_f, geom)
# -------------------------------------------------------------------
# 2. Build graticule (lat/lon lines) and transform to spiral
# -------------------------------------------------------------------
def build_graticule():
grat_lines = []
# Parallels (latitude lines)
for lat in range(-80, 81, 20):
lons = np.linspace(-180, 180, 361)
lats = np.full_like(lons, lat, dtype=float)
x, y = lonlat_to_spiral(lons, lats)
grat_lines.append((x, y))
# Meridians (longitude lines)
for lon in range(-180, 181, 30):
lats = np.linspace(-90, 90, 361)
lons = np.full_like(lats, lon, dtype=float)
x, y = lonlat_to_spiral(lons, lats)
grat_lines.append((x, y))
return grat_lines
# -------------------------------------------------------------------
# 3. Load Natural Earth via cartopy.feature
# -------------------------------------------------------------------
def load_world():
"""
Get Natural Earth 'admin_0_countries' from cartopy.feature,
and compute an approximate latitude for coloring (centroid in a
projected CRS, then back to EPSG:4326 to read lat).
"""
# cartopy.feature will fetch/cache the shapefile internally
ne_countries = cfeature.NaturalEarthFeature(
category="cultural",
name="admin_0_countries",
scale="110m",
)
# Wrap geometries into a GeoDataFrame (lon/lat)
geoms = list(ne_countries.geometries())
world = gpd.GeoDataFrame({"geometry": geoms}, crs="EPSG:4326")
# Use a world-friendly projected CRS (World Cylindrical Equal Area)
world_proj = world.to_crs("ESRI:54034") # or "EPSG:6933"
# Centroids in projected space
cent_proj = world_proj.geometry.centroid
# Bring centroids back to lon/lat so we can read latitude
cent_ll = gpd.GeoSeries(cent_proj, crs=world_proj.crs).to_crs("EPSG:4326")
world["lat_centroid"] = cent_ll.y
return world
# -------------------------------------------------------------------
# 4. Main plotting routine
# -------------------------------------------------------------------
def plot_spiral_earth():
# Load Natural Earth
world = load_world()
# Transform geometries to Spiral Earth projection
world_spiral = world.copy()
world_spiral["geometry"] = world_spiral["geometry"].apply(spiral_geometry)
# Prepare figure
fig, ax = plt.subplots(figsize=(10, 8), dpi=300)
fig.patch.set_facecolor("black")
ax.set_facecolor("black")
# --- Color by latitude (intuitive) ---
norm = Normalize(vmin=-90, vmax=90)
cmap = plt.get_cmap("coolwarm") # blue → white → red
world_spiral.plot(
ax=ax,
column="lat_centroid", # stored before projection
cmap=cmap,
norm=norm,
edgecolor="#111111",
linewidth=0.3,
)
# Graticule to show distortion
grat_lines = build_graticule()
for x, y in grat_lines:
ax.plot(
x, y,
color=(1, 1, 1, 0.12),
linewidth=0.6,
linestyle="-",
)
# Cosmetic tweaks
ax.set_aspect("equal", "box")
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
# Colorbar explaining the colors = latitude
sm = ScalarMappable(norm=norm, cmap=cmap)
sm.set_array([])
cbar = fig.colorbar(
sm,
ax=ax,
orientation="vertical",
fraction=0.035,
pad=0.03,
)
cbar.set_label("Latitude (°)", color="0.8", fontsize=8)
cbar.ax.tick_params(colors="0.7", labelsize=7)
# Titles & attribution
fig.text(
0.5, 0.965,
MAIN_TITLE,
ha="center",
va="top",
fontsize=16,
color="white",
)
fig.text(
0.5, 0.92,
SUBTITLE,
ha="center",
va="top",
fontsize=9,
color="0.8",
)
fig.text(
0.5, 0.02,
ATTRIBUTION,
ha="center",
va="bottom",
fontsize=8,
color="0.7",
)
# Labels for poles
ax.text(
0.0, 0.0,
"South Pole\n(core)",
fontsize=7,
color="0.6",
ha="center",
va="center",
)
ax.text(
0.0, R_SCALE * 0.97,
"North Pole\n(outer ring)",
fontsize=7,
color="0.6",
ha="center",
va="bottom",
)
plt.tight_layout(rect=[0.03, 0.07, 0.97, 0.9])
plt.savefig(
FILENAME,
dpi=400,
facecolor=fig.get_facecolor(),
bbox_inches="tight",
)
plt.show()
# -------------------------------------------------------------------
# 5. Run
# -------------------------------------------------------------------
if __name__ == "__main__":
plot_spiral_earth()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment