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
This commit is contained in:
1 parent
69d0a641fe
commit
0f68e74e38
13 files changed
+1066
-50
No files matched your search
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/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)")
|
||||
Reference in new issue
Block a user