Files

184 lines
7.0 KiB
Python
Raw Permalink Normal View History

#!/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)")