Created
April 20, 2026 01:28
-
-
Save cvanelteren/d3ebf331cf36c84f5b8bac0c62831dc3 to your computer and use it in GitHub Desktop.
Flat projection of 60 degrees south with initial claimants
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # %% | |
| """ | |
| 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}") |
Author
cvanelteren
commented
Apr 20, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment