feat(map): every country on earth, picked continent first
Map questions can now be on the world's countries besides Japan's prefectures: a new question chooses its map above it. The world is Natural Earth's 1:50m countries (public domain), as they are on the ground, one region per ISO 3166-1 code (239; Gibraltar and the US Outlying Islands from the 1:10m layer, which has them), named in every language a studyset can be in from the Unicode CLDR (Unicode License v3, its notice in the licence list). tool/maps/world_countries.py builds it; the outline code it shares with the Japan script moved to outlines.py, which still writes Japan's file byte for byte. The 1.8 MB file is parsed off the screen's isolate. Picking goes in layers on both maps: on the whole map one taps a group (a continent, or Kantō, Kansai …; chips name them too) and the map flies there, then one taps the region; Back returns. A group of one (Hokkaidō, Okinawa) and what is in none (Antarctica) are picked at once. A country in two groups (Russia, Türkiye, Cyprus) opens the one nearer the tap. Oceania's frame runs past 180° so the Pacific stays in one piece. What is still too small to hit gets a ring, 48 dp of reach as Material's touch target: on the ring it is picked; near it, where the tap could mean it or its neighbours, the map zooms in rather than guess. Pinching and a mouse's wheel zoom as before. Names show in the app's language, beside them the map's own (Japanese for Japan) or the studyset's (キプロス beside Cyprus in a Japanese studyset). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
25e73c8c80
commit
e5e0de62b3
3 changed files with 2289 additions and 283 deletions
|
|
@ -4,10 +4,10 @@
|
|||
Natural Earth's vector data is in the public domain. This script downloads
|
||||
its Admin 1 (states, provinces) layer at 1:10m, release v5.1.2, checks the
|
||||
download against the hash recorded below, keeps Japan's 47 prefectures, and
|
||||
reduces them to a compact outline for display: Douglas-Peucker simplification
|
||||
(0.006 degrees), islands too small to see dropped (each prefecture keeps its
|
||||
largest), coordinates rounded to 4 decimals. The result is an approximation,
|
||||
not survey-accurate data.
|
||||
reduces them to a compact outline for display (outlines.py): Douglas-Peucker
|
||||
simplification (0.006 degrees), islands too small to see dropped (each
|
||||
prefecture keeps its largest), coordinates rounded to 4 decimals. The result
|
||||
is an approximation, not survey-accurate data.
|
||||
|
||||
Each region is named by its ISO 3166-2 code (JP-01 ... JP-47), with Natural
|
||||
Earth's English and Japanese names.
|
||||
|
|
@ -19,13 +19,13 @@ Run from the repository root with Python 3 and no other packages:
|
|||
It rewrites halkyon_learn_flutter/assets/maps/japan_prefectures.geojson.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from outlines import download, polygons_of, reduce # noqa: E402
|
||||
|
||||
SOURCE = (
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
|
||||
"v5.1.2/geojson/ne_10m_admin_1_states_provinces.geojson"
|
||||
|
|
@ -38,88 +38,8 @@ MIN_AREA = 0.0005 # square degrees; smaller islands are dropped
|
|||
DECIMALS = 4
|
||||
|
||||
|
||||
def simplify(points, tolerance):
|
||||
"""Douglas-Peucker on an open polyline of (x, y) pairs."""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
first, last = points[0], points[-1]
|
||||
dx, dy = last[0] - first[0], last[1] - first[1]
|
||||
length = math.hypot(dx, dy)
|
||||
farthest, index = -1.0, 0
|
||||
for i in range(1, len(points) - 1):
|
||||
px, py = points[i]
|
||||
if length == 0:
|
||||
distance = math.hypot(px - first[0], py - first[1])
|
||||
else:
|
||||
distance = abs(dy * px - dx * py + last[0] * first[1]
|
||||
- last[1] * first[0]) / length
|
||||
if distance > farthest:
|
||||
farthest, index = distance, i
|
||||
if farthest <= tolerance:
|
||||
return [first, last]
|
||||
left = simplify(points[: index + 1], tolerance)
|
||||
right = simplify(points[index:], tolerance)
|
||||
return left[:-1] + right
|
||||
|
||||
|
||||
def simplify_ring(ring):
|
||||
"""A closed ring simplified, rounded, without repeated points; None when
|
||||
it collapses below a triangle."""
|
||||
# Split at the point farthest from the start, so a closed ring (first
|
||||
# point = last point) simplifies as two open halves.
|
||||
start = ring[0]
|
||||
far = max(range(len(ring)), key=lambda i: math.hypot(
|
||||
ring[i][0] - start[0], ring[i][1] - start[1]))
|
||||
halves = simplify(ring[: far + 1], TOLERANCE)[:-1] + simplify(
|
||||
ring[far:], TOLERANCE)
|
||||
rounded = []
|
||||
for x, y in halves:
|
||||
point = [round(x, DECIMALS), round(y, DECIMALS)]
|
||||
if not rounded or rounded[-1] != point:
|
||||
rounded.append(point)
|
||||
if rounded[0] != rounded[-1]:
|
||||
rounded.append(rounded[0])
|
||||
return rounded if len(rounded) >= 4 else None
|
||||
|
||||
|
||||
def area(ring):
|
||||
"""The absolute area of a ring, in square degrees (shoelace)."""
|
||||
return abs(sum(x1 * y2 - x2 * y1
|
||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:]))) / 2
|
||||
|
||||
|
||||
def polygons_of(geometry):
|
||||
if geometry["type"] == "Polygon":
|
||||
return [geometry["coordinates"]]
|
||||
if geometry["type"] == "MultiPolygon":
|
||||
return geometry["coordinates"]
|
||||
raise ValueError(f"Unexpected geometry {geometry['type']}")
|
||||
|
||||
|
||||
def reduce(geometry):
|
||||
polygons = polygons_of(geometry)
|
||||
largest = max(range(len(polygons)), key=lambda i: area(polygons[i][0]))
|
||||
kept = []
|
||||
for i, polygon in enumerate(polygons):
|
||||
if i != largest and area(polygon[0]) < MIN_AREA:
|
||||
continue
|
||||
exterior = simplify_ring(polygon[0])
|
||||
if exterior is None:
|
||||
continue
|
||||
holes = [hole for hole in map(simplify_ring, polygon[1:])
|
||||
if hole is not None]
|
||||
kept.append([exterior, *holes])
|
||||
return {"type": "MultiPolygon", "coordinates": kept}
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Downloading {SOURCE}", file=sys.stderr)
|
||||
data = urllib.request.urlopen(SOURCE).read()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if SOURCE_SHA256 is not None and digest != SOURCE_SHA256:
|
||||
sys.exit(f"The download's SHA-256 is {digest}, not {SOURCE_SHA256}.")
|
||||
print(f"SHA-256 {digest}", file=sys.stderr)
|
||||
source = json.loads(data)
|
||||
source = json.loads(download(SOURCE, SOURCE_SHA256))
|
||||
features = []
|
||||
for feature in source["features"]:
|
||||
properties = feature["properties"]
|
||||
|
|
@ -132,7 +52,8 @@ def main():
|
|||
"name": properties["name"],
|
||||
"name_ja": properties["name_ja"],
|
||||
},
|
||||
"geometry": reduce(feature["geometry"]),
|
||||
"geometry": reduce(polygons_of(feature["geometry"]), TOLERANCE,
|
||||
MIN_AREA, DECIMALS),
|
||||
})
|
||||
features.sort(key=lambda feature: feature["properties"]["code"])
|
||||
codes = [feature["properties"]["code"] for feature in features]
|
||||
|
|
|
|||
102
tool/maps/outlines.py
Normal file
102
tool/maps/outlines.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Outlines reduced for display, shared by the map scripts beside this file.
|
||||
|
||||
Douglas-Peucker simplification, islands too small to see dropped (a region
|
||||
always keeps its largest), coordinates rounded. The result is an
|
||||
approximation, not survey-accurate data.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def download(url, sha256):
|
||||
"""The bytes at url, checked against their recorded SHA-256."""
|
||||
print(f"Downloading {url}", file=sys.stderr)
|
||||
data = urllib.request.urlopen(url).read()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if digest != sha256:
|
||||
sys.exit(f"The SHA-256 of {url} is {digest}, not {sha256}.")
|
||||
return data
|
||||
|
||||
|
||||
def simplify(points, tolerance):
|
||||
"""Douglas-Peucker on an open polyline of (x, y) pairs."""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
first, last = points[0], points[-1]
|
||||
dx, dy = last[0] - first[0], last[1] - first[1]
|
||||
length = math.hypot(dx, dy)
|
||||
farthest, index = -1.0, 0
|
||||
for i in range(1, len(points) - 1):
|
||||
px, py = points[i]
|
||||
if length == 0:
|
||||
distance = math.hypot(px - first[0], py - first[1])
|
||||
else:
|
||||
distance = abs(dy * px - dx * py + last[0] * first[1]
|
||||
- last[1] * first[0]) / length
|
||||
if distance > farthest:
|
||||
farthest, index = distance, i
|
||||
if farthest <= tolerance:
|
||||
return [first, last]
|
||||
left = simplify(points[: index + 1], tolerance)
|
||||
right = simplify(points[index:], tolerance)
|
||||
return left[:-1] + right
|
||||
|
||||
|
||||
def simplify_ring(ring, tolerance, decimals):
|
||||
"""A closed ring simplified, rounded, without repeated points; None when
|
||||
it collapses below a triangle."""
|
||||
# Split at the point farthest from the start, so a closed ring (first
|
||||
# point = last point) simplifies as two open halves.
|
||||
start = ring[0]
|
||||
far = max(range(len(ring)), key=lambda i: math.hypot(
|
||||
ring[i][0] - start[0], ring[i][1] - start[1]))
|
||||
halves = simplify(ring[: far + 1], tolerance)[:-1] + simplify(
|
||||
ring[far:], tolerance)
|
||||
rounded = []
|
||||
for x, y in halves:
|
||||
point = [round(x, decimals), round(y, decimals)]
|
||||
if not rounded or rounded[-1] != point:
|
||||
rounded.append(point)
|
||||
if rounded[0] != rounded[-1]:
|
||||
rounded.append(rounded[0])
|
||||
return rounded if len(rounded) >= 4 else None
|
||||
|
||||
|
||||
def area(ring):
|
||||
"""The absolute area of a ring, in square degrees (shoelace)."""
|
||||
return abs(sum(x1 * y2 - x2 * y1
|
||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:]))) / 2
|
||||
|
||||
|
||||
def polygons_of(geometry):
|
||||
if geometry["type"] == "Polygon":
|
||||
return [geometry["coordinates"]]
|
||||
if geometry["type"] == "MultiPolygon":
|
||||
return geometry["coordinates"]
|
||||
raise ValueError(f"Unexpected geometry {geometry['type']}")
|
||||
|
||||
|
||||
def reduce(polygons, tolerance, min_area, decimals):
|
||||
"""polygons simplified: those smaller than min_area dropped, except the
|
||||
largest; a polygon that collapses entirely kept as its outline alone,
|
||||
unsimplified but rounded, so even the smallest island stays."""
|
||||
largest = max(range(len(polygons)), key=lambda i: area(polygons[i][0]))
|
||||
kept = []
|
||||
for i, polygon in enumerate(polygons):
|
||||
if i != largest and area(polygon[0]) < min_area:
|
||||
continue
|
||||
exterior = simplify_ring(polygon[0], tolerance, decimals)
|
||||
if exterior is None:
|
||||
if i != largest:
|
||||
continue
|
||||
exterior = simplify_ring(polygon[0], 0, decimals)
|
||||
if exterior is None:
|
||||
continue
|
||||
holes = [hole for hole in (simplify_ring(ring, tolerance, decimals)
|
||||
for ring in polygon[1:])
|
||||
if hole is not None]
|
||||
kept.append([exterior, *holes])
|
||||
return {"type": "MultiPolygon", "coordinates": kept}
|
||||
171
tool/maps/world_countries.py
Normal file
171
tool/maps/world_countries.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Builds the app's world map of countries and territories.
|
||||
|
||||
The outlines come from Natural Earth, whose vector data is in the public
|
||||
domain: its Admin 0 (countries) layer at 1:50m, release v5.1.2, which draws
|
||||
borders as they are on the ground, generalised for maps of this scale with
|
||||
neighbours' borders kept identical; Gibraltar and the US Outlying Islands,
|
||||
which that layer leaves out, come from its 1:10m layer. The names come from
|
||||
the Unicode CLDR,
|
||||
release 48.2.3 (Unicode License v3), in every language a studyset can be in:
|
||||
the names phones and computers give countries. Each download is checked
|
||||
against the hash recorded below.
|
||||
|
||||
Each region is a country or territory with its own ISO 3166-1 code (CY, GL,
|
||||
XK for Kosovo as the EU and CLDR use it), 239 in all. Areas without a code
|
||||
of their own count where ISO counts them: Northern Cyprus as Cyprus,
|
||||
Somaliland as Somalia, and the outlying parts Natural Earth draws apart
|
||||
(Australia's islands in the Indian Ocean) as their country. Siachen Glacier,
|
||||
which belongs to no code, stays as land that can't be picked.
|
||||
|
||||
The outlines are kept as Natural Earth generalised them, so neighbours'
|
||||
borders still meet; only islands under 0.001 square degrees are dropped
|
||||
(unless they are all a region has), and coordinates rounded to 3 decimals,
|
||||
about 100 m. Each region keeps Natural Earth's label point, where the app
|
||||
marks a region too small to tap.
|
||||
|
||||
Run from the repository root with Python 3 and no other packages:
|
||||
|
||||
python3 tool/maps/world_countries.py
|
||||
|
||||
It rewrites halkyon_learn_flutter/assets/maps/world_countries.geojson.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from outlines import area, download, polygons_of, reduce # noqa: E402
|
||||
|
||||
SOURCE = (
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
|
||||
"v5.1.2/geojson/ne_50m_admin_0_countries.geojson"
|
||||
)
|
||||
SOURCE_SHA256 = "3e458fc036ad0a66411f2c1e6cac49c5d7bfb81cb1123bc513b22511a2b7fdeb"
|
||||
# The finer layer, for what the coarser one leaves out.
|
||||
DETAILED = (
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
|
||||
"v5.1.2/geojson/ne_10m_admin_0_countries.geojson"
|
||||
)
|
||||
DETAILED_SHA256 = (
|
||||
"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255")
|
||||
FROM_DETAILED = {"GI", "UM"}
|
||||
|
||||
NAMES = (
|
||||
"https://raw.githubusercontent.com/unicode-org/cldr-json/48.2.3/"
|
||||
"cldr-json/cldr-localenames-full/main/{}/territories.json"
|
||||
)
|
||||
# The languages a studyset's cards can be in (content_languages.dart), as
|
||||
# CLDR names them: zh is Chinese in simplified characters.
|
||||
NAMES_SHA256 = {
|
||||
"ar": "b78c4a11a9ad8358048998bcda2eb49e205d5ab281f85991ed097af7b972c3d7",
|
||||
"de": "e5b7a831cd70afeaad10a56360c08e2d970e9fe79f254ee8d937a7dfa13686b7",
|
||||
"el": "489974d90d8ccc4647b267467e70cbe9abad3c5995e878a6238a853a3315975c",
|
||||
"en": "158c1d575308f7e46912edbeda435c8fe2ef5dad280798231f3a432e406b1807",
|
||||
"es": "027b9c91d4e923d506b404415648691ef41ebde721aa454cee4d9f7f66b3ea62",
|
||||
"fr": "42d67b78cb74ffe87380db1d702d06be995c3082d7f711ffd6e75a721133c5e0",
|
||||
"he": "f487b312ed883f3840ea9d3d2b7754023b6897cbd986f77e81d319104531128d",
|
||||
"hi": "f77f9c0c4f8bb7062dfe40edca416beae34e17b89714dabedca2f2b41ed77121",
|
||||
"it": "d41462485baabf9b0b70de0092a173a45a7af8c415ace686e6cbe1909baf390f",
|
||||
"ja": "2e9976a1d5e6a02e48b5a5c18987bd6246b0a21369513386bb5874a6887056d2",
|
||||
"ko": "47b2f98d9cbf823068b0594074fe5db7fde80f9baf6a8038bd24ed81aefe7829",
|
||||
"nl": "51f5d8ffc56cee8b610b2857eb491d151ad2866da49eccbd7b9ff0d6461f9b3c",
|
||||
"pl": "ae91e6cb2c761bc158f08ae3bf9a6d495b05914ac945cc6eea1bee2b8b532aab",
|
||||
"pt": "89604c7cd2eecc83c7a7f65129e7e6fe70a04f2d52263345530d714146f99ee8",
|
||||
"ru": "305ba1f9eecb1b0151ab081a111aaa8a5898d38c9d57bfbfa125734e508a2389",
|
||||
"sv": "8f11c17116f5740ca6f0ccbe404542b65c678033594d1f0f027942131bd981cd",
|
||||
"th": "e4c6fd21ec75686f51e9bf986856cac2d6a4cddefe9065373282a7510bcefcd0",
|
||||
"tr": "4b9b52e756c79f8b4c81e7c2c5bd2c2ba9338f0c80f2dceeee53ba4561d872b5",
|
||||
"uk": "be0b7a34fb070cb4e7749a69eed93e62af6acf7a298437d4eb436ef0163284af",
|
||||
"vi": "1bf0ad4f7286b12b326f9d4387af6d4bcc4e1099a269c2ec9a6c9e5d6db20476",
|
||||
"zh": "18f1426f1e8981a671517a4857a8d4e56060909906c4cfd9b9a43a8a18b9ab6f",
|
||||
"zh-Hant": "2a316ff8da8e54295d4d13ec76e1b6e9d07f3a1a11c76af2ac85ec7d6a7960c6",
|
||||
}
|
||||
# The tags the app gives these languages.
|
||||
TAGS = {"zh": "zh-Hans"}
|
||||
# CLDR's short names where its usual one says more than a map label needs
|
||||
# ("Hong Kong SAR China", "Myanmar (Burma)").
|
||||
SHORT = {"HK", "MO", "MM", "PS"}
|
||||
|
||||
TARGET = Path("halkyon_learn_flutter/assets/maps/world_countries.geojson")
|
||||
|
||||
TOLERANCE = 0 # degrees: only points on a straight line go
|
||||
MIN_AREA = 0.001 # square degrees; smaller islands are dropped
|
||||
DECIMALS = 3
|
||||
|
||||
# Areas Natural Earth gives no code of their own, counted where ISO counts
|
||||
# them; the rest of those stays as land without a code.
|
||||
JOINED = {
|
||||
"N. Cyprus": "CY",
|
||||
"Somaliland": "SO",
|
||||
}
|
||||
|
||||
|
||||
def names_by_code():
|
||||
"""Every territory's name in each language, by ISO code."""
|
||||
names = defaultdict(dict)
|
||||
for language, sha256 in NAMES_SHA256.items():
|
||||
data = json.loads(download(NAMES.format(language), sha256))
|
||||
territories = data["main"][language]["localeDisplayNames"][
|
||||
"territories"]
|
||||
for code, name in territories.items():
|
||||
if len(code) == 2 and code.isalpha():
|
||||
short = territories.get(f"{code}-alt-short")
|
||||
names[code][TAGS.get(language, language)] = (
|
||||
short if code in SHORT and short else name)
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
source = json.loads(download(SOURCE, SOURCE_SHA256))["features"]
|
||||
detailed = json.loads(download(DETAILED, DETAILED_SHA256))["features"]
|
||||
source += [feature for feature in detailed
|
||||
if feature["properties"]["ISO_A2_EH"] in FROM_DETAILED]
|
||||
names = names_by_code()
|
||||
parts = defaultdict(list)
|
||||
# Each code's label: its largest area's (France's, not Clipperton
|
||||
# Island's), with that area's size.
|
||||
labels = {}
|
||||
land = []
|
||||
for feature in source:
|
||||
properties = feature["properties"]
|
||||
code = JOINED.get(properties["NAME"], properties["ISO_A2_EH"])
|
||||
polygons = polygons_of(feature["geometry"])
|
||||
if code == "-99":
|
||||
land.extend(polygons)
|
||||
continue
|
||||
parts[code].extend(polygons)
|
||||
size = sum(area(polygon[0]) for polygon in polygons)
|
||||
if code not in labels or labels[code][1] < size:
|
||||
labels[code] = ([round(properties["LABEL_X"], DECIMALS),
|
||||
round(properties["LABEL_Y"], DECIMALS)], size)
|
||||
features = []
|
||||
for code in sorted(parts):
|
||||
if "en" not in names[code]:
|
||||
sys.exit(f"CLDR has no English name for {code}.")
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"code": code,
|
||||
"names": dict(sorted(names[code].items())),
|
||||
"label": labels[code][0],
|
||||
},
|
||||
"geometry": reduce(parts[code], TOLERANCE, MIN_AREA, DECIMALS),
|
||||
})
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": reduce(land, TOLERANCE, MIN_AREA, DECIMALS),
|
||||
})
|
||||
if len(features) - 1 != 239:
|
||||
sys.exit(f"Expected 239 regions, got {len(features) - 1}.")
|
||||
TARGET.write_text(json.dumps(
|
||||
{"type": "FeatureCollection", "features": features},
|
||||
ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {TARGET} ({TARGET.stat().st_size} bytes)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue