release: bump version to 0.58.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
This commit is contained in:
linkong
2026-05-15 17:40:07 +08:00
parent dd176a6ae6
commit 93eb41a9f7
75 changed files with 5217 additions and 716 deletions

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Build a usable high-precision China-POV Earth boundary GeoJSON artifact."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
from pathlib import Path
from typing import Any
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--admin0-source", type=Path, required=True)
parser.add_argument("--coastline-source", type=Path, required=True)
parser.add_argument("--claims-source", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, default=Path("frontend/public/earth/data/boundaries/v1"))
parser.add_argument("--build-input-hash", required=True)
return parser.parse_args()
def load_geojson(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)
if payload.get("type") == "Feature":
payload = {"type": "FeatureCollection", "features": [payload]}
if payload.get("type") != "FeatureCollection":
raise ValueError(f"{path} must be a GeoJSON FeatureCollection or Feature")
return payload
def feature_collection(features: list[dict[str, Any]], **extra: Any) -> dict[str, Any]:
return {"type": "FeatureCollection", **extra, "features": features}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def apply_china_pov_admin0(admin0: dict[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for feature in admin0.get("features") or []:
props = dict(feature.get("properties") or {})
name = props.get("ADMIN") or props.get("NAME") or props.get("NAME_EN")
iso = props.get("ISO_A3") or props.get("ADM0_A3")
if iso == "TWN" or name == "Taiwan":
props.update(
{
"ADMIN": "China",
"NAME": "China",
"NAME_EN": "China",
"NAME_ZH": "中国",
"SOVEREIGNT": "China",
"SOV_A3": "CHN",
"ISO_A3": "CHN",
"ADM0_A3": "CHN",
"BRK_A3": "CHN",
"PLANET_POV_NOTE": "Taiwan/Penghu rendered as China for china-pov-v1",
}
)
elif iso == "KOS" or name == "Kosovo":
props.update(
{
"ADMIN": "Serbia",
"NAME": "Serbia",
"NAME_EN": "Serbia",
"NAME_ZH": "塞尔维亚",
"SOVEREIGNT": "Serbia",
"SOV_A3": "SRB",
"ISO_A3": "SRB",
"ADM0_A3": "SRB",
"BRK_A3": "SRB",
"PLANET_POV_NOTE": "Kosovo rendered as Serbia for china-pov-v1",
}
)
elif iso == "PSE" or name == "Palestine":
props.update(
{
"ADMIN": "Palestine",
"NAME": "Palestine",
"NAME_EN": "Palestine",
"NAME_ZH": "巴勒斯坦",
"ISO_A3": "PSE",
"ADM0_A3": "PSE",
"BRK_A3": "PSE",
"PLANET_POV_NOTE": "Gaza is rendered within Palestine for china-pov-v1",
}
)
out.append({**feature, "properties": props})
return out
def normalize_claim_features(claims: dict[str, Any]) -> list[dict[str, Any]]:
features = []
for feature in claims.get("features") or []:
if not feature.get("geometry"):
continue
props = dict(feature.get("properties") or {})
props.setdefault("name", "九段线")
props.setdefault("name_en", "Nine-dash line")
props["PLANET_LAYER"] = "claim_line"
features.append({**feature, "properties": props})
return features
def main() -> None:
args = parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
admin0 = load_geojson(args.admin0_source)
coastline = load_geojson(args.coastline_source)
claims = load_geojson(args.claims_source)
admin_features = apply_china_pov_admin0(admin0)
coastline_features = [
{**feature, "properties": {**(feature.get("properties") or {}), "PLANET_LAYER": "coastline"}}
for feature in coastline.get("features") or []
if feature.get("geometry")
]
claim_features = normalize_claim_features(claims)
hover_path = args.output_dir / "hover-index.geojson"
base_path = args.output_dir / "base.geojson"
claim_path = args.output_dir / "china-claims.geojson"
manifest_path = args.output_dir / "manifest.json"
hover = feature_collection(admin_features, name="earth-boundaries-china-pov-hover")
base = feature_collection(
[*admin_features, *coastline_features],
name="earth-boundaries-china-pov-base",
)
claim_fc = feature_collection(claim_features, name="earth-boundaries-china-pov-claims")
for path, payload in ((hover_path, hover), (base_path, base), (claim_path, claim_fc)):
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
f.write("\n")
manifest = {
"version": "china-pov-v1",
"builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(),
"tileProvider": "geojson-high-precision",
"format": "geojson",
"buildInputHash": args.build_input_hash,
"base": base_path.name,
"hoverIndex": hover_path.name,
"claimLine": claim_path.name,
"sourceFeatureCount": len(admin_features),
"coastlineFeatureCount": len(coastline_features),
"claimFeatureCount": len(claim_features),
"tiles": {"minZoom": 0, "maxZoom": 0},
"chinaClaims": {"available": bool(claim_features), "path": claim_path.name},
"sha256": {
"base": sha256_file(base_path),
"hoverIndex": sha256_file(hover_path),
"claimLine": sha256_file(claim_path),
},
"povPolicy": {
"profile": "china-pov-v1",
"applied": True,
"notes": [
"Taiwan/Penghu rendered as China.",
"Kosovo rendered as Serbia.",
"Gaza rendered within Palestine.",
"Nine-dash line rendered as a claim-line layer.",
"Zangnan/Aksai Chin require an audited polygon override package for exact subtract/union geometry.",
],
},
}
with manifest_path.open("w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
f.write("\n")
print(
json.dumps(
{
"status": "success",
"provider": "geojson-high-precision",
"manifest": str(manifest_path),
"admin0_features": len(admin_features),
"coastline_features": len(coastline_features),
"claim_features": len(claim_features),
},
ensure_ascii=False,
sort_keys=True,
)
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Build the production Earth boundary PMTiles artifact from collected sources."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--admin0-source", type=Path, required=True)
parser.add_argument("--coastline-source", type=Path, required=True)
parser.add_argument("--claims-source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--build-input-hash", required=True)
parser.add_argument("--pov-policy", type=Path, default=None)
parser.add_argument("--min-zoom", type=int, default=0)
parser.add_argument("--max-zoom", type=int, default=10)
return parser.parse_args()
def require_tool(name: str) -> str:
path = shutil.which(name)
if not path:
raise RuntimeError(f"required external tool not found in PATH: {name}")
return path
def load_geojson(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)
if payload.get("type") != "FeatureCollection":
raise ValueError(f"{path} must be a GeoJSON FeatureCollection")
return payload
def iter_layer_features(payload: dict[str, Any], layer: str) -> list[dict[str, Any]]:
out = []
for feature in payload.get("features") or []:
if not feature.get("geometry"):
continue
props = dict(feature.get("properties") or {})
props["tippecanoe"] = {"layer": layer}
out.append(
{
"type": "Feature",
"geometry": feature["geometry"],
"properties": props,
}
)
return out
def write_ndjson(path: Path, features: list[dict[str, Any]]) -> None:
with path.open("w", encoding="utf-8") as f:
for feature in features:
f.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":")))
f.write("\n")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> None:
args = parse_args()
tippecanoe = require_tool("tippecanoe")
pmtiles = require_tool("pmtiles")
admin0 = load_geojson(args.admin0_source)
coastline = load_geojson(args.coastline_source)
claims = load_geojson(args.claims_source)
features = [
*iter_layer_features(admin0, "boundary_admin0"),
*iter_layer_features(coastline, "coastline"),
*iter_layer_features(claims, "claim_line"),
]
if not features:
raise RuntimeError("no features available for PMTiles build")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.manifest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="planet-earth-boundaries-") as tmp:
tmp_dir = Path(tmp)
ndjson_path = tmp_dir / "earth-boundaries.ndjson"
mbtiles_path = tmp_dir / "earth-boundaries.mbtiles"
write_ndjson(ndjson_path, features)
subprocess.run(
[
tippecanoe,
"--force",
"--read-parallel",
"--minimum-zoom",
str(args.min_zoom),
"--maximum-zoom",
str(args.max_zoom),
"--no-tile-compression",
"--output",
str(mbtiles_path),
str(ndjson_path),
],
check=True,
)
subprocess.run([pmtiles, "convert", str(mbtiles_path), str(args.output)], check=True)
manifest = {
"version": "china-pov-v1",
"builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(),
"tileProvider": "pmtiles-mvt",
"format": "pmtiles+mvt",
"buildInputHash": args.build_input_hash,
"pmtiles": {
"path": os.path.relpath(args.output, args.manifest.parent),
"sha256": sha256_file(args.output),
},
"tiles": {
"minZoom": args.min_zoom,
"maxZoom": args.max_zoom,
},
"layers": ["boundary_admin0", "boundary_disputed_internal", "coastline", "claim_line"],
"povPolicy": {
"path": str(args.pov_policy) if args.pov_policy else None,
"applied": False,
"note": "This builder packages collected sources. Full China POV geometry overlay is still a source-prep responsibility.",
},
"base": None,
"hoverIndex": None,
}
with args.manifest.open("w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
f.write("\n")
print(
json.dumps(
{
"status": "success",
"output": str(args.output),
"features": len(features),
"format": "pmtiles+mvt",
},
ensure_ascii=False,
sort_keys=True,
)
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Check whether the Earth boundary pipeline is ready for PMTiles production.
This is intentionally a verifier, not a fake builder. It fails when the repo is
still using seed data or when the external MVT/PMTiles toolchain is absent.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from pathlib import Path
from typing import Any
DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json")
DEFAULT_ARTIFACT = Path("frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles")
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT)
args = parser.parse_args()
config = load_json(args.config)
policy = config.get("policy") or {}
collector_configs = config.get("collectorConfigs") or {}
failures: list[str] = []
warnings: list[str] = []
if policy.get("productionTileFormat") != "pmtiles+mvt":
failures.append("policy.productionTileFormat must be pmtiles+mvt")
if not policy.get("povPolicyPath"):
failures.append("policy.povPolicyPath is required")
required_kinds = {"admin0-boundaries", "coastline", "claim-lines"}
configured_kinds = {str(source.get("sourceKind")) for source in collector_configs.values()}
missing_kinds = sorted(required_kinds - configured_kinds)
if missing_kinds:
failures.append(f"missing production source kind(s): {', '.join(missing_kinds)}")
for collector_name, source in collector_configs.items():
endpoint = str(source.get("endpoint") or "")
if not endpoint or endpoint.startswith("https://example.com/"):
failures.append(f"{collector_name} endpoint must be configured to an audited production source")
for tool in ("tippecanoe", "pmtiles"):
if shutil.which(tool) is None:
failures.append(f"external tool not found in PATH: {tool}")
if not args.artifact.exists():
warnings.append(f"PMTiles artifact does not exist yet: {args.artifact}")
result = {
"ready": not failures,
"failures": failures,
"warnings": warnings,
"artifact": str(args.artifact),
}
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0 if not failures else 2
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Collect versioned source files for the Earth boundary tile pipeline.
This is an offline ingestion helper, not a runtime service. It downloads or
copies declared source packages, verifies optional sha256 checksums, and writes
a manifest that the tile builder can reference during audited rebuilds.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json")
DEFAULT_OUTPUT = Path("data/earth-boundary-sources")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser.parse_args()
def load_config(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)
if not isinstance(payload.get("sources"), list):
raise ValueError(f"{path} must contain a sources array")
return payload
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_filename(source: dict[str, Any]) -> str:
name = str(source.get("name") or source.get("id") or "source")
filename = source.get("filename")
if filename:
return Path(str(filename)).name
suffix = Path(str(source.get("url") or source.get("path") or name)).suffix
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in name).strip("-")
return f"{cleaned or 'source'}{suffix or '.dat'}"
def collect_source(source: dict[str, Any], output_dir: Path) -> dict[str, Any]:
source_id = source.get("id")
if not source_id:
raise ValueError("each source requires an id")
target = output_dir / safe_filename(source)
target.parent.mkdir(parents=True, exist_ok=True)
if source.get("url"):
request = urllib.request.Request(
str(source["url"]),
headers={"User-Agent": "planet-earth-boundary-source-collector/1.0"},
)
with urllib.request.urlopen(request, timeout=60) as response, target.open("wb") as f:
shutil.copyfileobj(response, f)
elif source.get("path"):
shutil.copy2(Path(str(source["path"])).expanduser(), target)
else:
raise ValueError(f"source {source_id} requires url or path")
actual_sha256 = sha256_file(target)
expected_sha256 = source.get("sha256")
if expected_sha256 and actual_sha256.lower() != str(expected_sha256).lower():
target.unlink(missing_ok=True)
raise ValueError(
f"source {source_id} sha256 mismatch: expected {expected_sha256}, got {actual_sha256}"
)
return {
"id": source_id,
"name": source.get("name") or source_id,
"kind": source.get("kind") or "unknown",
"license": source.get("license"),
"pov": source.get("pov"),
"path": str(target),
"sha256": actual_sha256,
"source": source.get("url") or source.get("path"),
"notes": source.get("notes"),
}
def collect_policy(config: dict[str, Any]) -> dict[str, Any] | None:
policy = config.get("policy") or {}
policy_path_value = policy.get("povPolicyPath")
if not policy_path_value:
return None
policy_path = Path(str(policy_path_value)).expanduser()
if not policy_path.exists():
raise FileNotFoundError(f"POV policy file not found: {policy_path}")
with policy_path.open("r", encoding="utf-8") as f:
policy_payload = json.load(f)
return {
"profile": policy_payload.get("profile") or policy.get("profile"),
"path": str(policy_path),
"sha256": sha256_file(policy_path),
"schema": policy_payload.get("schema"),
"ruleCount": len(policy_payload.get("rules") or []),
"productionTileFormat": policy.get("productionTileFormat"),
"debugTileFormat": policy.get("debugTileFormat"),
}
def main() -> None:
args = parse_args()
config = load_config(args.config)
args.output.mkdir(parents=True, exist_ok=True)
collected = [collect_source(source, args.output) for source in config["sources"]]
pov_policy = collect_policy(config)
manifest = {
"schema": "planet-earth-boundary-sources/v1",
"sources": collected,
"povPolicy": pov_policy,
"policy": config.get("policy", {}),
}
manifest_path = args.output / "manifest.json"
with manifest_path.open("w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
f.write("\n")
print(json.dumps({"manifest": str(manifest_path), "sources": len(collected)}, sort_keys=True))
if __name__ == "__main__":
main()

View File

@@ -1,103 +0,0 @@
param(
[string]$ListenAddress = "0.0.0.0",
[Parameter(Mandatory = $true)][int]$ListenPort,
[Parameter(Mandatory = $true)][string]$TargetHost,
[Parameter(Mandatory = $true)][int]$TargetPort,
[int]$IdleExitSeconds = 8
)
$ErrorActionPreference = "Stop"
Add-Type -TypeDefinition @"
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
public static class PlanetTcpRelay
{
public static async Task RunAsync(string listenAddress, int listenPort, string targetHost, int targetPort, int idleExitSeconds)
{
IPAddress address;
if (!IPAddress.TryParse(listenAddress, out address))
{
address = IPAddress.Any;
}
var listener = new TcpListener(address, listenPort);
listener.Start();
var lastHealthyAt = DateTime.UtcNow;
try
{
while (true)
{
if (await CanConnectAsync(targetHost, targetPort))
{
lastHealthyAt = DateTime.UtcNow;
}
else if ((DateTime.UtcNow - lastHealthyAt).TotalSeconds >= idleExitSeconds)
{
break;
}
while (listener.Pending())
{
var client = await listener.AcceptTcpClientAsync();
_ = Task.Run(() => HandleClientAsync(client, targetHost, targetPort));
}
await Task.Delay(200);
}
}
finally
{
listener.Stop();
}
}
private static async Task<bool> CanConnectAsync(string host, int port)
{
using (var client = new TcpClient())
{
var connectTask = client.ConnectAsync(host, port);
var completed = await Task.WhenAny(connectTask, Task.Delay(500));
if (completed != connectTask)
{
return false;
}
try
{
await connectTask;
return client.Connected;
}
catch
{
return false;
}
}
}
private static async Task HandleClientAsync(TcpClient client, string targetHost, int targetPort)
{
using (client)
using (var upstream = new TcpClient())
{
try
{
await upstream.ConnectAsync(targetHost, targetPort);
var clientStream = client.GetStream();
var upstreamStream = upstream.GetStream();
var toUpstream = clientStream.CopyToAsync(upstreamStream);
var toClient = upstreamStream.CopyToAsync(clientStream);
await Task.WhenAny(toUpstream, toClient);
}
catch
{
}
}
}
}
"@
[PlanetTcpRelay]::RunAsync($ListenAddress, $ListenPort, $TargetHost, $TargetPort, $IdleExitSeconds).GetAwaiter().GetResult()