Skip to content

Instantly share code, notes, and snippets.

@cvanelteren
Created April 20, 2026 01:28
Show Gist options
  • Select an option

  • Save cvanelteren/d3ebf331cf36c84f5b8bac0c62831dc3 to your computer and use it in GitHub Desktop.

Select an option

Save cvanelteren/d3ebf331cf36c84f5b8bac0c62831dc3 to your computer and use it in GitHub Desktop.
Flat projection of 60 degrees south with initial claimants
# %%
"""
Antarctic Territorial Claims — Flat Polar View
Top-down South Polar Stereographic map showing the seven national
territorial claims on Antarctica, with flags and color-coded labels
arranged around the 60°S circle.
Background imagery: Natural Earth shaded relief (stock_img).
"""
import cartopy.crs as ccrs
import matplotlib.path as mpath
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
from PIL import Image, ImageOps
# ── Configuration ─────────────────────────────────────────────────────
FLAG_DIR = "/tmp/flags"
OUTPUT = "south_pole_60S.png"
# Antarctic territorial claims: (lon_start, lon_end, color, country_code, label)
# Use None for label when a country has a split territory (e.g. Australia)
TERRITORIES = [
(-74, -25, "#4e79a7", "ar", "Argentina"),
(45, 136, "#f28e2b", "au", "Australia"),
(142, 160, "#f28e2b", "au", None), # Australia's eastern sector
(-90, -53, "#e15759", "cl", "Chile"),
(136, 142, "#b07aa1", "fr", "France"),
(160, 210, "#59a14f", "nz", "New Zealand"),
(-20, 45, "#76b7b2", "no", "Norway"),
(-80, -20, "#ff9da7", "gb", "United Kingdom"),
]
# Override the default midpoint angle for overlapping claims
# (Argentina and UK share nearly the same midpoint longitude)
ANGLE_OVERRIDES = {"ar": -40, "gb": -60}
PLATE_CARREE = ccrs.PlateCarree()
PROJ = ccrs.SouthPolarStereo()
# ── Helpers ───────────────────────────────────────────────────────────
def make_circular_boundary() -> mpath.Path:
"""Create a circular clipping path for the polar axes."""
theta = np.linspace(0, 2 * np.pi, 100)
verts = np.vstack([np.sin(theta), np.cos(theta)]).T
return mpath.Path(verts * 0.5 + 0.5)
def draw_territory_wedge(
ax, lon_start: float, lon_end: float, color: str,
lat_inner: float = -90, lat_outer: float = -60, alpha: float = 0.20,
):
"""Draw a filled wedge from the pole to 60°S between two longitudes,
with a dashed outline."""
n_points = max(int(abs(lon_end - lon_start)) * 2, 10)
lons_arc = np.linspace(lon_start, lon_end, n_points)
lons = np.concatenate([lons_arc, lons_arc[::-1], [lons_arc[0]]])
lats = np.concatenate([
np.full_like(lons_arc, lat_outer),
np.full_like(lons_arc, lat_inner),
[lat_outer],
])
ax.fill(lons, lats, transform=PLATE_CARREE, color=color, alpha=alpha)
ax.plot(lons, lats, transform=PLATE_CARREE, color=color, lw=0.8, ls="--", alpha=0.6)
def compute_axes_geometry(fig, ax):
"""Compute the axes center and radius in figure-fraction coordinates."""
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
ax_box = ax.get_window_extent(renderer)
fig_box = fig.bbox
cx = (ax_box.x0 + ax_box.x1) / 2 / fig_box.width
cy = (ax_box.y0 + ax_box.y1) / 2 / fig_box.height
r = min(ax_box.width, ax_box.height) / 2 / fig_box.width
return cx, cy, r
def compute_60s_radius(fig, ax) -> float:
"""Find the radius of the 60°S circle in figure-fraction units."""
r60_proj = abs(PROJ.transform_point(0, -60, PLATE_CARREE)[1])
pt_center = ax.transData.transform((0, 0))
pt_60 = ax.transData.transform((0, r60_proj))
return abs(pt_60[1] - pt_center[1]) / fig.bbox.height
def add_flag_with_shadow(fig, flag_code: str, x: float, y: float, zoom: float = 0.75):
"""Place a flag image at (x, y) in figure-fraction coordinates,
with a white border and subtle drop shadow."""
flag = Image.open(f"{FLAG_DIR}/{flag_code}.png").convert("RGBA")
flag = ImageOps.expand(flag, border=3, fill="white")
shadow_px = 2
w, h = flag.size
canvas = Image.new("RGBA", (w + shadow_px * 2, h + shadow_px * 2), (0, 0, 0, 0))
shadow = Image.new("RGBA", flag.size, (0, 0, 0, 40))
canvas.paste(shadow, (shadow_px * 2, shadow_px * 2))
canvas.paste(flag, (0, 0), flag)
ab = AnnotationBbox(
OffsetImage(np.array(canvas), zoom=zoom),
(x, y), xycoords="figure fraction", frameon=False,
)
fig.add_artist(ab)
def label_alignment(angle_deg: float, fx: float, fy: float):
"""Determine text position and alignment based on which side of
the circle the flag sits on."""
angle_deg = angle_deg % 360
if 45 < angle_deg < 135: # right side
return fx + 0.055, fy, "left", "center"
elif 225 < angle_deg < 315: # left side
return fx - 0.055, fy, "right", "center"
elif angle_deg <= 45 or angle_deg >= 315: # top
return fx, fy + 0.045, "center", "bottom"
else: # bottom
return fx, fy - 0.045, "center", "top"
# ── Build the map ─────────────────────────────────────────────────────
fig = plt.figure(figsize=(11, 11), facecolor="white")
ax = fig.add_axes([0.08, 0.08, 0.84, 0.84], projection=PROJ)
ax.set_extent([-180, 180, -90, -50], PLATE_CARREE)
ax.set_boundary(make_circular_boundary(), transform=ax.transAxes)
# Background imagery
ax.stock_img()
ax.coastlines(lw=0.6, color="#4a4a4a", zorder=2)
ax.gridlines(draw_labels=False, color="white", alpha=0.15, lw=0.5)
ax.patch.set_visible(False)
# Territory wedges
for lon0, lon1, color, _, _ in TERRITORIES:
draw_territory_wedge(ax, lon0, lon1, color)
# 60°S circle
circle_lons = np.linspace(0, 360, 361)
ax.plot(
circle_lons, np.full_like(circle_lons, -60),
color="#d63031", lw=2, transform=PLATE_CARREE,
ls=(0, (8, 4)), zorder=5,
)
# ── Place flags and labels ────────────────────────────────────────────
ax_cx, ax_cy, _ = compute_axes_geometry(fig, ax)
r60 = compute_60s_radius(fig, ax)
flag_r = r60 * 1.05 # flags sit just outside the 60°S circle
edge_r = r60 # connectors start at the circle
placed = set()
for lon0, lon1, color, code, label in TERRITORIES:
if label is None or code in placed:
continue
placed.add(code)
# Determine the angular position for this flag
mid_lon = ANGLE_OVERRIDES.get(code, (lon0 + lon1) / 2)
angle = np.deg2rad(mid_lon)
sin_a, cos_a = np.sin(angle), np.cos(angle)
# Flag and connector positions in figure-fraction coordinates
# (SouthPolarStereo: 0° longitude at top, longitude increases clockwise)
fx = ax_cx + flag_r * sin_a
fy = ax_cy + flag_r * cos_a
ex = ax_cx + edge_r * sin_a
ey = ax_cy + edge_r * cos_a
add_flag_with_shadow(fig, code, fx, fy)
# Connector line from 60°S circle to flag
fig.add_artist(plt.Line2D(
[ex, fx], [ey, fy],
transform=fig.transFigure,
color=color, lw=1.5, alpha=0.8,
solid_capstyle="round", clip_on=False,
))
# Anchor dot at the circle edge
fig.add_artist(plt.Line2D(
[ex], [ey],
transform=fig.transFigure,
marker="o", markersize=4, color=color,
lw=0, clip_on=False,
))
# Color-coded label
tx, ty, ha, va = label_alignment(np.rad2deg(angle), fx, fy)
fig.text(
tx, ty, label,
ha=ha, va=va,
fontsize=11, fontweight="bold", fontstyle="italic",
color=color,
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=1.0),
)
# ── 60°S annotation ──────────────────────────────────────────────────
# Place "60°S" inline at the top of the red dashed circle.
# In projected coordinates, the topmost point is at (0, +r60).
r60_proj_y = abs(PROJ.transform_point(0, -60, PLATE_CARREE)[1])
top_disp = ax.transData.transform((0, r60_proj_y))
top_fig = fig.transFigure.inverted().transform(top_disp)
fig.text(
top_fig[0], top_fig[1], " 60°S ",
ha="center", va="center",
fontsize=8.5, color="#d63031", fontweight="bold", fontstyle="italic",
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.9),
transform=fig.transFigure,
)
fig.savefig(OUTPUT, dpi=200, transparent=True)
print(f"Saved {OUTPUT}")
@cvanelteren
Copy link
Copy Markdown
Author

south_pole_60S

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment