Files
decePubClient/tools/globe/prepare-countries.py
T
thepraandClaude Opus 5.5 0f68e74e38
Build / Build (push) Successful in 46s
Deploy / decepub.thepra.dev (push) Successful in 1m52s
The globe under the Sun of now or of a post, with borders and country names
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar
coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side,
and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered,
focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The
panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its
hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt,
distance, irradiance, day length and the next equinox or solstice.

Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer:
largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural
Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 20:23:42 +02:00

185 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
# Builds wwwroot/vendor/globe/countries.json, the globe's country borders and name positions, from world-atlas 2.0.2's
# countries-50m.json (Natural Earth 1:50m, public domain; packaged under ISC by Mike Bostock), downloaded once from
# https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz
# usage: tools/globe/prepare-countries.py <countries-50m.json> <iso_3166-1.json> <output json> (not part of the build)
# iso_3166-1.json is the iso-codes package's (/usr/share/iso-codes/json/iso_3166-1.json); it maps world-atlas's numeric
# ids to the two-letter codes the browser names countries by (Intl.DisplayNames), so names follow the reader's language.
#
# Output: {"borders": [[lng, lat, lng, lat, ...], ...], "countries": [{"code", "name", "lat", "lng", "area"}, ...]}
# - borders are the arcs two countries share, land borders only: coastlines are in the texture already. Coordinates are
# rounded to 0.01° (about a kilometre).
# - each country's name sits in its largest polygon, at the point nearest the polygon's centroid that is still at least
# half as far from the edges as the pole of inaccessibility (the point farthest from them, as Mapbox's polylabel finds
# it): inside the country even for a crescent, and in the middle of a long one (central Italy, not the Po valley).
# - area is the country's, size the square root of its largest polygon's (where the name is), both in degrees scaled by
# the cosine of the latitude; the globe shows a name once its polygon is large enough on screen to hold it.
import heapq
import json
import math
import sys
def decode(topology):
scale_x, scale_y = topology["transform"]["scale"]
translate_x, translate_y = topology["transform"]["translate"]
arcs = []
for arc in topology["arcs"]:
x = y = 0
points = []
for dx, dy in arc:
x += dx
y += dy
points.append((x * scale_x + translate_x, y * scale_y + translate_y))
arcs.append(points)
return arcs
def ring(arcs, indexes):
points = []
for index in indexes:
arc = arcs[index] if index >= 0 else list(reversed(arcs[~index]))
points.extend(arc if not points else arc[1:])
return points
def polygons(geometry):
if geometry["type"] == "Polygon":
return [geometry["arcs"]]
if geometry["type"] == "MultiPolygon":
return geometry["arcs"]
return []
def planar(points, latitude):
# an equirectangular plane squeezed by the cosine of the polygon's latitude, so distances are roughly true there
k = math.cos(math.radians(latitude))
return [(x * k, y) for x, y in points]
def area(points):
return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]))) / 2
def distance_to_rings(x, y, rings):
inside = False
best = math.inf
for points in rings:
for (ax, ay), (bx, by) in zip(points, points[1:] + points[:1]):
if (ay > y) != (by > y) and x < (bx - ax) * (y - ay) / (by - ay) + ax:
inside = not inside
dx, dy = bx - ax, by - ay
t = 0 if dx == dy == 0 else max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)))
best = min(best, (x - ax - t * dx) ** 2 + (y - ay - t * dy) ** 2)
return (1 if inside else -1) * math.sqrt(best)
def polylabel(rings, precision=0.05):
xs = [x for x, _ in rings[0]]
ys = [y for _, y in rings[0]]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
size = min(max_x - min_x, max_y - min_y)
if size == 0:
return min_x, min_y
half = size / 2
cells = []
def push(x, y, h):
d = distance_to_rings(x, y, rings)
heapq.heappush(cells, (-(d + h * math.sqrt(2)), d, x, y, h))
x = min_x
while x < max_x:
y = min_y
while y < max_y:
push(x + half, y + half, half)
y += size
x += size
best_d = distance_to_rings((min_x + max_x) / 2, (min_y + max_y) / 2, rings)
best = ((min_x + max_x) / 2, (min_y + max_y) / 2)
while cells:
bound, d, x, y, h = heapq.heappop(cells)
if d > best_d:
best_d, best = d, (x, y)
if -bound - best_d <= precision:
continue
h /= 2
for sx in (-1, 1):
for sy in (-1, 1):
push(x + sx * h, y + sy * h, h)
return best, best_d
def centroid(points):
twice = 0
x = y = 0
for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]):
cross = x1 * y2 - x2 * y1
twice += cross
x += (x1 + x2) * cross
y += (y1 + y2) * cross
if twice == 0:
return points[0]
return x / (3 * twice), y / (3 * twice)
def label_point(rings):
(best_x, best_y), best_d = polylabel(rings)
if best_d <= 0:
return best_x, best_y
cx, cy = centroid(rings[0])
xs = [x for x, _ in rings[0]]
ys = [y for _, y in rings[0]]
step = max(max(xs) - min(xs), max(ys) - min(ys)) / 80
chosen, chosen_distance = (best_x, best_y), math.hypot(best_x - cx, best_y - cy)
x = min(xs)
while x <= max(xs):
y = min(ys)
while y <= max(ys):
to_centroid = math.hypot(x - cx, y - cy)
if to_centroid < chosen_distance and distance_to_rings(x, y, rings) >= best_d / 2:
chosen, chosen_distance = (x, y), to_centroid
y += step
x += step
return chosen
topology = json.load(open(sys.argv[1]))
codes = {entry["numeric"]: entry["alpha_2"] for entry in json.load(open(sys.argv[2]))["3166-1"]}
arcs = decode(topology)
geometries = topology["objects"]["countries"]["geometries"]
uses = [0] * len(arcs)
for geometry in geometries:
for polygon in polygons(geometry):
for indexes in polygon:
for index in indexes:
uses[index if index >= 0 else ~index] += 1
borders = [[round(c, 2) for point in arc for c in point] for arc, used in zip(arcs, uses) if used >= 2]
countries = []
for geometry in geometries:
shapes = []
for polygon in polygons(geometry):
outer = ring(arcs, polygon[0])
latitude = sum(y for _, y in outer) / len(outer)
shapes.append((area(planar(outer, latitude)), latitude, [ring(arcs, r) for r in polygon]))
if not shapes:
continue
largest, latitude, rings = max(shapes, key=lambda s: s[0])
k = math.cos(math.radians(latitude))
x, y = label_point([planar(r, latitude) for r in rings])
countries.append({
"code": codes.get(str(geometry.get("id", "")).zfill(3)),
"name": geometry.get("properties", {}).get("name"),
"lat": round(y, 2),
"lng": round(x / k, 2),
"area": round(sum(s[0] for s in shapes), 1),
"size": round(math.sqrt(largest), 2)
})
json.dump({"borders": borders, "countries": sorted(countries, key=lambda c: -c["area"])}, open(sys.argv[3], "w"), separators=(",", ":"))
print(f"{len(borders)} border lines ({sum(len(b) // 2 for b in borders)} points), {len(countries)} countries "
f"({sum(1 for c in countries if c['code'] is None)} without an ISO code)")