Add fe-map and iss sources, The Shape of Distance as published, contribution guide

- femap/: planar best-effort solver, build, result and page (fe-map.aethercosmology.com)
- satellites/: The Sync Test build, ISS elements, SatNOGS timings and page (iss.aethercosmology.com)
- src/handoff/The_Shape_of_Distance_PRESENTATION.html: inputs for both
- CONTRIBUTING.md; README and LICENSE-DATA cover all four sites; GitHub mirror for issues and pull requests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Toby
2026-10-30 18:42:34 -04:00
parent 2bf0bdccb5
commit ad8b889437
27 changed files with 4474 additions and 43 deletions
+59
View File
@@ -0,0 +1,59 @@
# Contributing
Corrections, new measurements and independent re-analyses are welcome,
including ones that disagree with the results. Every figure on pingstats,
fe-map and iss is rebuilt from this repository, so a change accepted here
changes the published numbers. The Shape of Distance is included as published.
Issues and pull requests: https://github.com/aetherbird/cable-latency-survey
(a mirror of https://git.aethercosmology.com/toby/cable-latency-survey with the same history).
## Report a problem
Open an issue that names the page, the pair or value, what you believe is
wrong, and the evidence (a source, a log, a measurement ID). Typical cases: a
server or probe listed in the wrong city, a flight time that does not match its
track log, a cable length that differs from its published figure, or a step in
a method that you think biases the result.
## Add measurements
Measurements that anyone can re-fetch are the most useful. Include the raw
output, not only a summary.
| Kind | What to send |
|---|---|
| Ping or traceroute | A RIPE Atlas or Globalping measurement ID; or the raw output of `ping -c 20` and `mtr -rwc 20` with the UTC time, the target, and the source city (a public anchor, a hosted server, or your city to within 25 km). |
| Flight | Flight number and date for both directions, the track log URL (FlightAware or another ADS-B record), and the aircraft type. |
| Rail or road | The route and its published length, with the source. |
| Satellite pass | SatNOGS observation IDs, or raw recordings with station location and UTC timestamps. |
Routes in the southern hemisphere are the most informative: South America to
Africa and Australia, Africa to Australia, and New Zealand to anywhere.
Do not post your home IP address or street address. A city is enough.
## Change the analysis
Fork the repository, make the change, run the build of the page it affects and
the browser checks, and open a pull request that states how the published
numbers change.
| Site | Source | Build |
|---|---|---|
| pingstats.aethercosmology.com | `src/` | `cd src && python3 build.py` |
| fe-map.aethercosmology.com | `femap/` | `cd femap && python3 build.py` (about 10 minutes) |
| iss.aethercosmology.com | `satellites/` | `cd satellites && python3 build.py` |
| distance.aethercosmology.com | `src/handoff/The_Shape_of_Distance_PRESENTATION.html` | single published file |
Browser checks: `cd tests && npm install && CH=/path/to/chromium ./run.sh`.
## Rules applied to every contribution
- Each value traces to a public source, a measurement ID, or a raw log in this
repository.
- A measurement is not removed for disagreeing with a model. Cleaning rules,
such as the one for mislocated servers, are stated in each page's method
section and apply to every measurement alike.
- Code is contributed under the MIT licence (`LICENSE`); data under CC BY 4.0
(`LICENSE-DATA.md`).
+6 -1
View File
@@ -10,7 +10,9 @@ portions listed below. This covers:
- `src/data/` (Globalping results, Vultr and residential logs, routes, fits,
network attribution);
- `cable_latency_survey_data.json`, `cable_latency_survey_data_v2.json`;
- `cable_latency_survey_report.html` and `src/site/index.html`.
- `cable_latency_survey_report.html` and `src/site/index.html`;
- `femap/data/`, `femap/site/`, `satellites/data/sync.json`, `satellites/site/`;
- `src/handoff/The_Shape_of_Distance_PRESENTATION.html`.
Attribution: *Cable Latency Survey (2026), https://pingstats.aethercosmology.com*.
@@ -23,6 +25,9 @@ Attribution: *Cable Latency Survey (2026), https://pingstats.aethercosmology.com
| RIPE Atlas (RIPE NCC) | Anchor metadata (`src/raw/ripe_anchors*.json`), traceroute results (`src/data/trace.json`, `src/raw/trace/`). | RIPE NCC terms for RIPE Atlas data. Attribution: RIPE Atlas, RIPE NCC. |
| RIPEstat (RIPE NCC) | RPKI validation results, `src/data/rpki.json`. | RIPE NCC terms. |
| Globalping (jsDelivr) | Probe results in `src/data/live_*.json`; probe list `src/raw/gp_probes.json`. | Globalping terms. Attribution: Globalping. |
| FlightAware, flightsfrom.com | Flight times in The Shape of Distance and `femap/`: per-direction median airborne times derived from FlightAware track logs (each linked), schedules from flightsfrom.com (each route linked). The logs themselves are not redistributed. | © FlightAware; © flightsfrom.com; their terms apply to the underlying records. |
| CelesTrak | ISS orbital elements, `satellites/data/iss_25544_20261028.tle` | CelesTrak (data from the U.S. Space Force, space-track.org); CelesTrak's terms apply. |
| SatNOGS (Libre Space Foundation) | ISS recordings and their timings in The Shape of Distance; `satellites/data/satnogs_20261003.json` and the values derived from them in `satellites/` | CC BY-SA 4.0. Attribution: SatNOGS Network, Libre Space Foundation. |
| GeoNames | `src/raw/geonames/cities15000.txt` | CC BY 4.0, https://www.geonames.org |
| Natural Earth, via world-atlas | `src/raw/geo/land-110m.json` | Natural Earth: public domain. world-atlas: ISC. |
| d3 | `src/raw/lib/d3.min.js`, embedded in the report | ISC, Mike Bostock. |
+13 -8
View File
@@ -1,14 +1,16 @@
# Cable Latency Survey
Measured round-trip times between cities, compared with the minimum
propagation time along submarine-cable routes on four models of the Earth's
surface: a sphere, a North-pole azimuthal equidistant (AE) map, an AE map
centred on 0°N 0°E ("dual-pole"), and a flat Mercator map. A round-trip time
below the fibre propagation floor along a model's route is inconsistent with
that model.
Measurements, code and data behind four sites:
| Site | Contents | Source |
|---|---|---|
| https://pingstats.aethercosmology.com | Measured round-trip times between cities, compared with the minimum propagation time along submarine-cable routes on a sphere, a North-pole azimuthal equidistant (AE) map, an AE map centred on 0°N 0°E ("dual-pole") and a flat Mercator map | `src/` |
| https://fe-map.aethercosmology.com | The best layout of 219 cities on a plane that a solver can find from timed flights, pings, rail and road lengths and satellite passes, and the same solve released into 3D | `femap/` |
| https://iss.aethercosmology.com | The Sync Test: the ISS at its published-orbit position on the globe and three flat maps, with a spoofed radio signal moving at the speed measured by SatNOGS stations | `satellites/` |
| https://distance.aethercosmology.com | The Shape of Distance: flights, triangles and satellite recordings | `src/handoff/The_Shape_of_Distance_PRESENTATION.html` |
- Report: https://pingstats.aethercosmology.com (also `cable_latency_survey_report.html`, a single file that works offline)
- Code: MIT (`LICENSE`). Measurements and derived data: CC BY 4.0, with third-party terms (`LICENSE-DATA.md`).
- Corrections, new measurements and re-analyses: `CONTRIBUTING.md`. Issues and pull requests on the GitHub mirror, https://github.com/aetherbird/cable-latency-survey.
## Files
@@ -18,7 +20,10 @@ that model.
| `cable_latency_survey_data.json` | v1: the same measurements without coordinates. Kept for reference. |
| `cable_latency_survey_report.html` | The report, identical to the website. |
| `src/` | Pipeline code, raw logs and derived data (below). |
| `tests/` | Headless-browser checks of the report (`tests/run.sh`). |
| `femap/` | fe-map: solver (`fit.py`), build (`build.py`, `template.html`), result (`data/femap.json`), page (`site/`). |
| `satellites/` | iss: build (`build.py`, `template.html`), orbital elements and SatNOGS timings (`data/`), page (`site/`). |
| `src/handoff/The_Shape_of_Distance_PRESENTATION.html` | The Shape of Distance as published; the flights, limits and triangles used by `femap/` and the SatNOGS timings used by `satellites/` are read from it. |
| `tests/` | Headless-browser checks of the sites (`tests/run.sh`). |
### Raw measurements
+11 -7
View File
@@ -222,7 +222,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<div class="hero" id="hero">
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
<div class="hud tl">
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
<h1>Cable Latency Survey</h1>
<p>Measured round-trip times between cities, compared with the minimum propagation time along submarine-cable routes on four models of the Earth's surface.</p>
</div>
@@ -328,7 +327,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<section>
<h2 class="sec">Code and data</h2>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed.</p>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed. Corrections, new measurements and re-analyses are welcome, including ones that disagree with the results.</p>
<div class="dl" id="code"></div>
<pre id="repro"></pre>
</section>
@@ -353,7 +352,7 @@ const MNAME=Object.fromEntries(MODELS),NK={globe:"globe",ae:"ae",dp:"dualpole",m
const reduce=matchMedia("(prefers-reduced-motion: reduce)").matches;
const S={src:"all",view:"ae",sel:null,hover:null,site:null,onlyBad:false,pulses:!reduce,mode:"cable",tab:"route"};
const MODE={cable:"sea-cable route",line:"straight line"};
const GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const GH="https://github.com/aetherbird/cable-latency-survey",GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const lnk=(href,t)=>`<a href="${href}" target="_blank" rel="noopener">${t}</a>`;
const netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
@@ -605,8 +604,12 @@ function readout(){
function segDist(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy;let t=l?((px-a[0])*dx+(py-a[1])*dy)/l:0;t=Math.max(0,Math.min(1,t));return Math.hypot(px-a[0]-t*dx,py-a[1]-t*dy)}
function nearest(mx,my){
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
let best=null,bd=7;for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<bd){bd=d;best={pair:i}}}
return best;
const dm=new Map();for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<7&&d<(dm.get(i)??1e9))dm.set(i,d)}
if(!dm.size)return null;
/* routes sharing a cable overlap: take the one drawn on top (selected, then red, orange, cyan) */
const m=Math.min(...dm.values());let best=null,bp=-1;
for(const [i,d] of dm){if(d>m+1.5)continue;const pr=i===S.sel?9:rt(LP[i]).tier;if(pr>bp||(pr===bp&&d<dm.get(best))){bp=pr;best=i}}
return{pair:best};
}
let drag=null,hoverRAF=false;
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
@@ -911,7 +914,8 @@ function renderCode(){
["analyze_wn.py","WonderNetwork archive: statistics, mislocated servers"],["cables.py","published cable lengths on each model"],["network.py","cable-and-land routing on each model"],["trace.py","RIPE Atlas sea crossings"],
["asmap.py","network attribution from public BGP data"],["pingmap.py","shape fit without positions"],["build.py","this page"],["export.py","JSON data hand-off"]];
document.getElementById("code").innerHTML=`
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))}</div>
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))} · mirror ${lnk(GH,GH.replace("https://",""))}</div>
<div>Contribute</div><div>${lnk(GH+"/blob/main/CONTRIBUTING.md","How to contribute")} · ${lnk(GH+"/issues","issues")} · ${lnk(GH+"/pulls","pull requests")}</div>
<div>Data hand-off</div><div>${lnk(RAWV+"cable_latency_survey_data_v2.json","cable_latency_survey_data_v2.json")} (every measurement, site, route and cable path) · ${lnk(RAWV+"cable_latency_survey_data.json","version 1")}</div>
<div>Globalping</div><div>${gp.map((r,i)=>f(r.file,`run ${i+1}`)).join(" · ")}: raw results; each carries its Globalping measurement ID</div>
<div>Vultr servers</div><div>${vps.map(v=>`${v}: ${f(`data/vps/${v}/ping.log`,"ping")}, ${f(`data/vps/${v}/mtr.log`,"traceroute")}`).join(" · ")}</div>
@@ -989,7 +993,7 @@ const ia=LS.findIndex(s=>s.name==="Sydney"),ib=LS.findIndex(s=>s.name==="Aucklan
S.sel=LP.find(p=>(p.a===ia&&p.b===ib)||(p.a===ib&&p.b===ia)).idx;
document.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
document.getElementById("srcsel").value=S.src;
{document.getElementById("eyeb").textContent=`${LS.length} sites · ${Object.keys(D.netlines).length} cables · 4 models`;
{
document.getElementById("step1").textContent=`Globalping probes, rented Vultr servers and a residential connection measured round-trip times to RIPE Atlas anchors and to each other across ${LS.length} sites. The minimum for each pair is used.`}
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
+345
View File
@@ -0,0 +1,345 @@
"""The Best Flat Map: every measured length and limit laid out on one plane, then released into 3D.
Data (all published earlier on distance.aethercosmology.com and pingstats.aethercosmology.com):
flights The Measured Sphere, "each flight's own speed": 219 cities, 1,344 routes flown both ways.
Length = aircraft cruise speed x mean airborne time, less 15 min for climb and descent.
limits The Measured Sphere's cable, rail, road and satellite-ranging limits, every WonderNetwork
2020 round trip and every live round trip (RIPE Atlas, Globalping, Vultr) between servers
in these cities.
triangles The Triangle Test's 935 surveyed triangles (scored, not solved: a plane gives every
triangle exactly 180 degrees).
Coordinates are used to label cities, draw the start maps, match servers to cities and score the
globe for comparison. The solver (fit.py) sees only lengths and limits.
../.venv/bin/python build.py -> data/femap.json, site/index.html
"""
import base64, datetime as dt, importlib.util, json, math, os, pickle, re, sys, time
for _v in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
from multiprocessing import Pool
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "src")
sys.path.insert(0, SRC)
_spec = importlib.util.spec_from_file_location("pingstats_build", os.path.join(SRC, "build.py"))
_pb = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(_pb)
live, land = _pb.live, _pb.land # src/build.py
from models import R, C_FIBER, globe as gc, ae_xy, dualpole_xy, mercator_xy
from fit import Problem, sphere_fit
PRES = os.path.join(SRC, "handoff", "The_Shape_of_Distance_PRESENTATION.html")
KM_PER_MS = C_FIBER / 2 / 1000 # one-way km per ms of round trip, light in glass
END_SLACK = 25.0 # km allowed at each end of a round trip
MATCH_KM = 80.0 # a server belongs to a city if it is this close to its airport
LO, HI = 0.25, 0.10 # default flight band: [0.75 L, 1.10 L]
BANDS = [(0.25, 0.10), (0.25, 0.25), (0.40, 0.10), (0.50, 0.10)]
N_RANDOM = 300
FAST = os.environ.get("FAST") == "1" # quick preview: one start, one 3D seed, one band -> data/preview.json, site/preview.html
SEED = 20261030
FA_PREFIX = "https://www.flightaware.com/live/flight/"
def pages():
s = open(PRES, encoding="utf-8").read()
out = []
for b in re.findall(r"[A-Za-z0-9+/]{20000,}={0,2}", s):
out.append(base64.b64decode(b).decode("utf-8", "ignore"))
return out
def page_const(html, name):
js = next(x for x in re.findall(r"<script[^>]*>(.*?)</script>", html, flags=re.S) if f"const {name}=" in x)
i = js.index(f"const {name}=") + len(name) + 7
return json.JSONDecoder().raw_decode(js[i:])[0]
def aeq(lat, lon, lat0, lon0):
"""Azimuthal equidistant map centred on (lat0, lon0), km. North-pole AE is (90, 0); dual-pole is (0, 0)."""
p1, l1, p0, l0 = map(np.radians, (np.asarray(lat), np.asarray(lon), lat0, lon0))
cc = np.sin(p0) * np.sin(p1) + np.cos(p0) * np.cos(p1) * np.cos(l1 - l0)
c = np.arccos(np.clip(cc, -1, 1)); k = np.where(c < 1e-9, 1.0, c / np.sin(np.maximum(c, 1e-9)))
x = R * k * np.cos(p1) * np.sin(l1 - l0)
y = R * k * (np.cos(p0) * np.sin(p1) - np.sin(p0) * np.cos(p1) * np.cos(l1 - l0))
return np.c_[x, y]
def plane_d(f, a, b):
x1, y1 = f(*a); x2, y2 = f(*b)
return math.hypot(x2 - x1, y2 - y1)
def shortest_on_any(a, b):
"""Shortest distance between two places on the globe or any of the three flat maps used elsewhere."""
return min(gc(*a, *b), plane_d(ae_xy, a, b), plane_d(dualpole_xy, a, b), plane_d(mercator_xy, a, b))
def load():
P = pages()
ms = page_const(P[6], "DATA")["own"]
tri = page_const(P[7], "FIN")["tri"]
cities = ms["cities"]
C = [(c[1], c[2]) for c in cities]
n = len(C)
fl = []
for f in ms["flights"]:
a, b, x = f[0], f[1], f[8]
ab, ba = x["ab"], x["ba"]
fl.append(dict(a=a, b=b, L=x["km"], airline=f[4], fa=f[5], fb=f[6], page=f[7],
ab=[ab["ident"], ab["ac"], ab["air"], ab["n"], round(ab["v"]), ab["src"]],
ba=[ba["ident"], ba["ac"], ba["air"], ba["n"], round(ba["v"]), ba["src"]]))
lim = {} # (a, b, kind) -> record; round trips keep the tightest per pair
def add(a, b, U, kind, note, label="", url=""):
if a == b:
return
a, b = min(a, b), max(a, b)
k = (a, b, kind)
if k not in lim or U < lim[k]["U"]:
lim[k] = dict(a=a, b=b, U=round(U, 1), kind=kind, note=note, label=label, url=url)
kinds = {"cable": "rt", "rail": "rail", "road": "road", "satellite": "sat"}
for l in ms["limits"]:
add(l[0], l[1], l[2], kinds[l[3]], l[4].replace("round trip ", ""), l[5], l[6] if len(l) > 6 else "")
def nearest(p):
d = [gc(*p, *q) for q in C]; i = int(np.argmin(d)); return i, d[i]
dropped = []
wn = json.load(open(os.path.join(SRC, "data", "wn_clean.json")))
srv = wn["servers"]; wm = {k: nearest((s["lat"], s["lon"])) for k, s in srv.items()}
n_wn = 0
# A round trip shorter than every surface allows means a server is not where it is listed. A server
# caught in two or more such pairs is treated as mislocated and all its pairs are left out.
reach_of = lambda p: KM_PER_MS * p["rtt"] + 2 * END_SLACK
bad = [p for p in wn["pairs"] if reach_of(p) < min(p["globe"], p["ae"], p["dualpole"], p["mercator"])]
hits = {}
for p in bad:
for s in (p["a"], p["b"]):
hits[s] = hits.get(s, 0) + 1
mislocated = {s for s, k in hits.items() if k >= 2}
for p in bad:
dropped.append(f'{srv[p["a"]]["name"]} - {srv[p["b"]]["name"]} ({p["rtt"]} ms)')
for p in wn["pairs"]:
a, b = wm[p["a"]], wm[p["b"]]
if a[1] > MATCH_KM or b[1] > MATCH_KM or p in bad or p["a"] in mislocated or p["b"] in mislocated:
continue
reach = reach_of(p)
n_wn += 1
add(a[0], b[0], reach + a[1] + b[1], "rt",
f'{p["rtt"]:.2f} ms, WonderNetwork 2020 archive ({p["n"]} samples)',
f'WonderNetwork servers {srv[p["a"]]["name"]} and {srv[p["b"]]["name"]}', "https://wondernetwork.com/pings")
lv = live(); sites = lv["sites"]; sm = [nearest((s[2], s[3])) for s in sites]
n_live = 0
for c in lv["cells"]:
a, b = sm[c[0]], sm[c[1]]
if a[1] > MATCH_KM or b[1] > MATCH_KM:
continue
sa, sb = sites[c[0]], sites[c[1]]
reach = KM_PER_MS * c[2] + 2 * END_SLACK
if reach < shortest_on_any((sa[2], sa[3]), (sb[2], sb[3])):
dropped.append(f"{sa[0]} - {sb[0]} ({c[2]} ms)")
continue
n_live += 1
add(a[0], b[0], reach + a[1] + b[1], "rt", f"{c[2]:.1f} ms, live measurement 2026 ({c[5]})",
f"{sa[0]} probe to {sb[0]} anchor", "https://pingstats.aethercosmology.com/")
lim = sorted(lim.values(), key=lambda r: (r["kind"], r["a"], r["b"]))
tri_fail = dict(n=len(tri), flat=sum(abs(t[3]) > 4 for t in tri), globe=sum(abs(t[2]) > 4 for t in tri))
return dict(cities=cities, C=np.array(C), fl=fl, lim=lim, tri=tri_fail, dropped=dropped, n_wn=n_wn, n_live=n_live,
mislocated=sorted(f'{srv[s]["name"]} ({srv[s]["country"]})' for s in mislocated))
def mds_start(prob, C):
"""Classical multidimensional scaling of the shortest flight-path lengths, turned and mirrored
(no stretching) to sit as close as possible to the North-pole AE layout for drawing."""
n = prob.n; D = np.full((n, n), np.inf); np.fill_diagonal(D, 0)
for a, b, L in zip(prob.fa, prob.fb, prob.fl):
D[a, b] = D[b, a] = min(D[a, b], L)
for k in range(n):
D = np.minimum(D, D[:, k:k + 1] + D[k:k + 1, :])
J = np.eye(n) - 1 / n; B = -0.5 * J @ (D ** 2) @ J
w, V = np.linalg.eigh(B); X = V[:, -2:] * np.sqrt(np.maximum(w[-2:], 0))
T = aeq(C[:, 0], C[:, 1], 90, 0)
Xc, Tc = X - X.mean(0), T - T.mean(0)
U, _, Vt = np.linalg.svd(Xc.T @ Tc)
return Xc @ (U @ Vt) + T.mean(0)
_PROB = None
def _init(prob):
global _PROB
_PROB = prob
def _run(job):
label, centre, X0, lo, hi = job[:5]
k, X = _PROB.best_effort(X0, lo, hi, **(job[5] if len(job) > 5 else {}))
r = _PROB.dists(X)[0] / _PROB.fl
return dict(label=label, centre=centre, broken=k, meanlog=float(np.abs(np.log(r)).mean()), X=X)
def counts(prob, X, lo, hi):
dd, ld = prob.dists(X); r = dd / prob.fl
return dict(short=int((r < 1 - lo).sum()), long=int((r > 1 + hi).sum()), limits=int((ld > prob.lu * 1.0005).sum()))
def main():
t0 = time.time()
D = load(); C = D["C"]; fl, lim = D["fl"], D["lim"]
prob = Problem(len(C), [f["a"] for f in fl], [f["b"] for f in fl], [f["L"] for f in fl],
[l["a"] for l in lim], [l["b"] for l in lim], [l["U"] for l in lim])
GF = np.array([gc(*C[f["a"]], *C[f["b"]]) for f in fl]); GL = np.array([gc(*C[l["a"]], *C[l["b"]]) for l in lim])
print(f"{len(C)} cities, {len(fl)} flights, {len(lim)} limits ({D['n_wn']} WonderNetwork pairs, {D['n_live']} live cells, "
f"{len(D['dropped'])} round trips dropped as mislocated)", flush=True)
starts = {"ae": ("North-pole AE", [90, 0], aeq(C[:, 0], C[:, 1], 90, 0)),
"dp": ("Dual-pole AE", [0, 0], aeq(C[:, 0], C[:, 1], 0, 0)),
"mds": ("Flight times only", [90, 0], mds_start(prob, C))}
rng = np.random.default_rng(SEED)
jobs = [(v[0], v[1], v[2], LO, HI) for v in starts.values()]
named = [(0, 90), (0, -90), (0, 180), (-90, 0), (45, 100), (60, -30), (30, 60), (-30, 150)]
rand = [(round(math.degrees(math.asin(rng.uniform(-1, 1))), 1), round(rng.uniform(-180, 180), 1)) for _ in range(N_RANDOM)]
jobs += [(f"AE centred on {la}, {lo}", [la, lo], aeq(C[:, 0], C[:, 1], la, lo), LO, HI) for la, lo in named + rand]
if FAST:
jobs = jobs[:1]
cache = os.path.join(HERE, "data", "search_fast.pkl" if FAST else "search.pkl")
sig = (len(C), len(fl), len(lim), LO, HI, len(jobs))
pool = Pool(7, initializer=_init, initargs=(prob,))
if os.path.exists(cache) and pickle.load(open(cache, "rb"))["sig"] == sig:
res = pickle.load(open(cache, "rb"))["res"]
print("search: cached", flush=True)
else:
res = pool.map(_run, jobs, chunksize=1)
pickle.dump(dict(sig=sig, res=res), open(cache, "wb"))
res.sort(key=lambda r: (r["broken"], r["meanlog"]))
# Refine the 20 best layouts: twelve rounds, shorter solves, two reweighting floors.
if not FAST:
ref = pool.map(_run, [(r["label"], r["centre"], r["X"], LO, HI, dict(rounds=12, maxiter=3000, eps=e))
for r in res[:20] for e in (0.02, 0.05)], chunksize=1)
for r in ref:
r["refined"] = True
print("refined:", sorted(r["broken"] for r in ref)[:10], flush=True)
res = sorted(res + ref, key=lambda r: (r["broken"], r["meanlog"]))
best = res[0]
print(f"search: {len(res)} starts in {time.time() - t0:.0f}s; best {best['broken']} broken from {best['label']}; "
f"worst start {res[-1]['broken']}", flush=True)
for r in res[:5]:
print(" ", r["broken"], round(r["meanlog"], 4), r["label"])
# Release the best flat layout into free 3D space: same data, same solver, the plane rule removed.
# In the same pool: the band table (each band solved from the best layout and from North-pole AE).
seeds = 1 if FAST else 6
rel = [("3D", None, np.c_[best["X"], np.random.default_rng(SEED + s).normal(0, 50, len(C))], LO, HI) for s in range(seeds)]
seeds_b = [r["X"] for r in res[:5]] + [starts["ae"][2]]
NB = len(seeds_b)
bjobs = [(f"band {lo} {hi}", None, X0, lo, hi) for lo, hi in (BANDS[:1] if FAST else BANDS) for X0 in seeds_b]
out_jobs = pool.map(_run, rel + bjobs, chunksize=1)
pool.close()
X3s = out_jobs[:seeds]
b3 = min(X3s, key=lambda t: t["broken"]); k3, X3 = b3["broken"], b3["X"]
sph = sphere_fit(X3)
print(f"3D: {k3} broken, radius {sph['radius']:.0f} km, wrap {sph['wrap']:.0f} deg, offset {sph['offset']:.3f}", flush=True)
bands = []
for j, (lo, hi) in enumerate(BANDS[:1] if FAST else BANDS):
cand = out_jobs[seeds + NB * j: seeds + NB * (j + 1)]
cand = cand + [dict(broken=prob.broken(best["X"], lo, hi), X=best["X"])]
X = min(cand, key=lambda t: t["broken"])["X"]; r = prob.dists(X)[0] / prob.fl; gr = GF / prob.fl
bands.append(dict(lo=lo, hi=hi, flat=dict(**counts(prob, X, lo, hi), below75=int((r < 0.75).sum()), below60=int((r < 0.6).sum())),
globe=dict(short=int((gr < 1 - lo).sum()), long=int((gr > 1 + hi).sum()), limits=int((GL > prob.lu * 1.0005).sum()),
below75=int((gr < 0.75).sum()), below60=int((gr < 0.6).sum())),
flat_long=[[int(prob.fa[i]), int(prob.fb[i]), round(float(r[i]), 3)] for i in np.where(r > 1 + hi)[0]]))
print("band", lo, hi, bands[-1]["flat"], bands[-1]["globe"], flush=True)
r = prob.dists(best["X"])[0] / prob.fl; gr = GF / prob.fl
stats = dict(
n_cities=len(C), n_flights=len(fl), n_limits=len(lim),
n_kind={k: sum(l["kind"] == k for l in lim) for k in ("rt", "rail", "road", "sat")},
n_wn=D["n_wn"], n_live=D["n_live"], dropped=D["dropped"], mislocated=D["mislocated"], n_starts=len(jobs),
best=dict(label=best["label"], centre=best["centre"], broken=best["broken"], **counts(prob, best["X"], LO, HI),
below80=int((r < 0.8).sum()), below85=int((r < 0.85).sum()), meanlog=best["meanlog"]),
globe=dict(**{k: int(v) for k, v in dict(short=(gr < 1 - LO).sum(), long=(gr > 1 + HI).sum(),
limits=(GL > prob.lu * 1.0005).sum()).items()},
below80=int((gr < 0.8).sum()), below85=int((gr < 0.85).sum()), meanlog=float(np.abs(np.log(gr)).mean())),
starts_broken=[[x["label"], x["broken"]] for x in res if "refined" not in x],
n_best=sum(x["broken"] == best["broken"] for x in res),
three=dict(broken=k3, **counts(prob, X3, LO, HI), **sph),
globe_long=[[int(prob.fa[i]), int(prob.fb[i]), round(float(gr[i]), 3)] for i in np.where(gr > 1 + HI)[0]],
globe_short=[[int(prob.fa[i]), int(prob.fb[i]), round(float(gr[i]), 3)] for i in np.where(gr < 1 - LO)[0]],
tri=D["tri"], lo=LO, hi=HI, km_per_ms=KM_PER_MS, end_slack=END_SLACK, match_km=MATCH_KM,
built=dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
def strip(u):
return u[len(FA_PREFIX):] if u.startswith(FA_PREFIX) else u
out = dict(
cities=D["cities"],
fl=[[f["a"], f["b"], round(f["L"], 1), f["airline"], f["fa"], f["fb"], f["page"],
f["ab"][:5] + [strip(f["ab"][5])], f["ba"][:5] + [strip(f["ba"][5])]] for f in fl],
lim=[[l["a"], l["b"], l["U"], l["kind"], l["note"], l["label"], l["url"]] for l in lim],
gf=[round(float(x), 1) for x in GF], gl=[round(float(x), 1) for x in GL],
land=land(),
starts={k: dict(label=v[0], centre=v[1], X=np.round(v[2], 1).tolist()) for k, v in starts.items()},
best=dict(label=best["label"], centre=best["centre"], X=np.round(best["X"], 1).tolist()),
best3=dict(X=np.round(X3, 1).tolist()),
bands=bands, stats=stats, fa_prefix=FA_PREFIX)
os.makedirs(os.path.join(HERE, "data"), exist_ok=True)
with open(os.path.join(HERE, "data", "preview.json" if FAST else "femap.json"), "w") as fh:
json.dump(out, fh, separators=(",", ":"))
print(json.dumps(stats, indent=1)[:4000])
render(out)
print(f"done in {time.time() - t0:.0f}s")
def render(out):
tpl = os.path.join(HERE, "template.html")
if not os.path.exists(tpl):
return
html = open(tpl, encoding="utf-8").read()
s = out["stats"]
vals = copy_values(out)
for k, v in vals.items():
html = html.replace("{{" + k + "}}", v)
left = sorted(set(re.findall(r"\{\{(\w+)\}\}", html)))
assert not left, left
html = html.replace("/*DATA*/null", json.dumps(out, separators=(",", ":")))
os.makedirs(os.path.join(HERE, "site"), exist_ok=True)
name = "preview.html" if FAST else "index.html"
open(os.path.join(HERE, "site", name), "w", encoding="utf-8").write(html)
print("site/" + name, round(len(html) / 1e6, 2), "MB")
def fmt(n):
return f"{n:,}"
def copy_values(out):
s = out["stats"]; b, g, t3 = s["best"], s["globe"], s["three"]
names = [c[0] for c in out["cities"]]
long_ = sorted(out["bands"][0]["flat_long"], key=lambda x: -x[2])
v = dict(
N_CITIES=fmt(s["n_cities"]), N_FLIGHTS=fmt(s["n_flights"]), N_LIMITS=fmt(s["n_limits"]),
N_RT=fmt(s["n_kind"]["rt"]), N_RAIL=fmt(s["n_kind"]["rail"] + s["n_kind"]["road"]), N_SAT=fmt(s["n_kind"]["sat"]),
N_STARTS=fmt(s["n_starts"]), N_TRI=fmt(s["tri"]["n"]), TRI_FLAT=fmt(s["tri"]["flat"]), TRI_GLOBE=fmt(s["tri"]["globe"]),
BEST_LONG=fmt(b["long"]), BEST_SHORT=fmt(b["short"]), BEST_LIM=fmt(b["limits"]), BEST_BROKEN=fmt(b["broken"]),
BEST_B80=fmt(b["below80"]), GLOBE_B80=fmt(g["below80"]), GLOBE_LONG=fmt(g["long"]), GLOBE_SHORT=fmt(g["short"]),
GLOBE_LIM=fmt(g["limits"]),
WORST_PAIR=f"{names[long_[0][0]]}–{names[long_[0][1]]}" if long_ else "–",
WORST_X=f"{long_[0][2]:.2f}" if long_ else "–",
LONG_LIST=", ".join(f"{names[a]}–{names[bb]} ×{x:.2f}" for a, bb, x in long_[:8]),
R3=fmt(round(t3["radius"])), WRAP3=f"{t3['wrap']:.0f}", OFF3=f"{t3['offset'] * 100:.1f}", BROKEN3=fmt(t3["broken"]),
LO_PCT=f"{round((1 - s['lo']) * 100)}", HI_PCT=f"{round((1 + s['hi']) * 100)}",
BUILT=s["built"], N_DROPPED=fmt(len(s["dropped"])), N_STARTS_AE=fmt(s["n_starts"] - 3), KM_PER_MS=f"{s['km_per_ms']:.1f}",
FIT_LO=next((f"{round((1 - x['lo']) * 100)}" for x in out["bands"] if x["flat"]["short"] + x["flat"]["long"] + x["flat"]["limits"] == 0), "–"),
FIT_B75=next((fmt(x["flat"]["below75"]) for x in out["bands"] if x["flat"]["short"] + x["flat"]["long"] + x["flat"]["limits"] == 0), "–"),
FIT_B60=next((fmt(x["flat"]["below60"]) for x in out["bands"] if x["flat"]["short"] + x["flat"]["long"] + x["flat"]["limits"] == 0), "–"),
GLOBE_B75=fmt(out["bands"][0]["globe"]["below75"]), GLOBE_B60=fmt(out["bands"][0]["globe"]["below60"]),
GLOBE_SHORT_LIST=", ".join(f"{names[a]}–{names[bb]}" for a, bb, x in sorted(s["globe_short"], key=lambda t: t[2])[:4]),
)
return v
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
"""Best-effort layout of cities on a plane (or in free 3D space) from measured lengths and limits.
Lengths (flights) must fall inside a band [L(1-lo), L(1+hi)]; limits (round trips, rail, road,
satellite passes) cap a distance from above. Nothing else is imposed: no latitude, longitude,
radius or projection enters the objective.
The search runs in two stages, mirrored by the page's live solver:
1. least squares on every flight's relative error (smooth, finds the overall shape);
2. reweighted pseudo-L1 on the amount by which each measurement falls outside its band, so the
solver gives up on as few measurements as it can rather than straining many (an approximation
to minimising the number broken).
"""
import numpy as np
from scipy.optimize import minimize
LIMIT_W = 3.0 # weight of a limit relative to a flight in stage 2
MARGIN = 0.005 # stage 2 aims this far inside each band
DELTA = 0.003 # pseudo-L1 smoothing
EPS = 0.02 # reweighting floor
ROUNDS = 8
class Problem:
def __init__(self, n, fa, fb, fl, la, lb, lu):
self.n = n
self.fa, self.fb, self.fl = np.asarray(fa), np.asarray(fb), np.asarray(fl, float)
self.la, self.lb, self.lu = np.asarray(la, int), np.asarray(lb, int), np.asarray(lu, float)
def _grad(self, dim, X, ga, gl):
G = np.empty((self.n, dim)); n = self.n
gv = ga[:, None] * (X[self.fa] - X[self.fb])
hv = gl[:, None] * (X[self.la] - X[self.lb]) if len(self.lu) else None
for q in range(dim):
G[:, q] = np.bincount(self.fa, gv[:, q], n) - np.bincount(self.fb, gv[:, q], n)
if hv is not None:
G[:, q] += np.bincount(self.la, hv[:, q], n) - np.bincount(self.lb, hv[:, q], n)
return G.ravel()
def dists(self, X):
dd = np.sqrt(((X[self.fa] - X[self.fb]) ** 2).sum(1)) + 1e-9
ld = np.sqrt(((X[self.la] - X[self.lb]) ** 2).sum(1)) + 1e-9 if len(self.lu) else np.zeros(0)
return dd, ld
def l2(self, dim, wl=100.0):
def f(x):
X = x.reshape(self.n, dim); dd, ld = self.dists(X)
e = dd / self.fl - 1; ex = np.maximum(0, ld / self.lu - 1)
return (e ** 2).sum() + wl * (ex ** 2).sum(), self._grad(dim, X, 2 * e / self.fl / dd, wl * 2 * ex / self.lu / ld)
return f
def sparse(self, dim, lo, hi, w, wl):
a = self.fl * (1 - lo + MARGIN); b = self.fl * (1 + hi - MARGIN); ub = self.lu * (1 - MARGIN)
def f(x):
X = x.reshape(self.n, dim); dd, ld = self.dists(X)
eh = np.maximum(0, dd / b - 1); el = np.maximum(0, 1 - dd / a)
sh = np.sqrt(eh ** 2 + DELTA ** 2); sl = np.sqrt(el ** 2 + DELTA ** 2)
ex = np.maximum(0, ld / ub - 1); sx = np.sqrt(ex ** 2 + DELTA ** 2)
F = (w * (sh + sl - 2 * DELTA)).sum() + (wl * (sx - DELTA)).sum()
ga = w * (eh / sh / b - el / sl / a) / dd
gl = wl * ex / sx / ub / ld
return F, self._grad(dim, X, ga, gl)
return f
def excess(self, X, lo, hi):
dd, ld = self.dists(X); r = dd / self.fl
return np.maximum(0, r / (1 + hi) - 1) + np.maximum(0, 1 - r / (1 - lo)), np.maximum(0, ld / self.lu - 1)
def broken(self, X, lo, hi):
e, l = self.excess(X, lo, hi)
return int((e > 0).sum() + (l > 5e-4).sum())
def best_effort(self, X0, lo, hi, rounds=ROUNDS, maxiter=30000, eps=EPS, limit_w=LIMIT_W):
dim = X0.shape[1]
run = lambda f, X: minimize(f, X.ravel(), jac=True, method="L-BFGS-B",
options={"maxiter": maxiter, "maxcor": 30}).x.reshape(self.n, dim)
X = run(self.l2(dim), X0)
best = (self.broken(X, lo, hi), X.copy())
w = np.ones(len(self.fl)); wl = np.full(len(self.lu), limit_w)
for _ in range(rounds):
X = run(self.sparse(dim, lo, hi, w, wl), X)
k = self.broken(X, lo, hi)
if k < best[0]:
best = (k, X.copy())
e, l = self.excess(X, lo, hi)
w = eps / (e + eps); wl = limit_w * eps / (l + eps)
return best
def sphere_fit(X):
"""Least-squares sphere through 3D points: radius, median offset (share of radius), and how far
round it the points reach (angle from their mean direction to the farthest point)."""
A = np.c_[2 * X, np.ones(len(X))]; s = np.linalg.lstsq(A, (X ** 2).sum(1), rcond=None)[0]
c = s[:3]; r = float(np.sqrt(s[3] + c @ c)); d = np.linalg.norm(X - c, axis=1)
u = (X - c) / d[:, None]; m = u.mean(0); m /= np.linalg.norm(m)
return dict(radius=r, offset=float(np.median(np.abs(d - r) / r)),
wrap=float(np.degrees(np.arccos(np.clip(u @ m, -1, 1))).max()), centre=c.tolist())
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+758
View File
@@ -0,0 +1,758 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Best Flat Map</title>
<meta name="description" content="{{N_CITIES}} cities, {{N_FLIGHTS}} timed flights and {{N_LIMITS}} measured limits laid out on a plane by a solver that keeps as many measurements as it can. The plane cannot close the southern routes; released into 3D, the same data closes into a ball.">
<meta property="og:type" content="website">
<meta property="og:title" content="The Best Flat Map">
<meta property="og:description" content="Every measured flight, ping, rail line and satellite pass on one plane. Best of {{N_STARTS}} starts: {{WORST_PAIR}} would need its aircraft at {{WORST_X}}× its top speed.">
<meta property="og:url" content="https://fe-map.aethercosmology.com/">
<meta property="og:image" content="https://fe-map.aethercosmology.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='3' y='9' width='26' height='14' rx='2' fill='%23081120' stroke='%233ee6ff' stroke-width='2'/%3E%3Cpath d='M8 19 L16 13 L24 18' fill='none' stroke='%23ff4d5e' stroke-width='2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
/* Layout: control bar, then the plane (map left, readouts right on wide screens), then the 3D release, then results and method in a ~74ch column. */
:root{
--bg:#02050c; --panel:#07101e; --panel2:#0b182b; --line:rgba(120,180,255,.14); --line2:rgba(120,180,255,.28);
--ink:#e8f0fb; --dim:#9badc6; --faint:#65789a;
--land:#16263d; --coast:#3a5a80; --city:#ffffff; --track:#3ee6ff; --ok:#3dffa0; --warn:#ffc94a; --bad:#ff4d5e;
--disp:"Space Grotesk","Inter",system-ui,sans-serif;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
color-scheme:dark;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);-webkit-font-smoothing:antialiased;
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:20;background:var(--bg)}
a{color:var(--track);text-decoration-color:rgba(62,230,255,.35);text-underline-offset:2px}
a:hover{text-decoration-color:var(--track)}
:focus-visible{outline:2px solid var(--track);outline-offset:2px}
.wrap{max-width:1720px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.top{padding-block:28px 6px}
h1{font:700 clamp(28px,3.2vw,40px)/1.1 var(--disp);margin:0;letter-spacing:-.01em;text-wrap:balance}
.sub{margin:6px 0 0;color:var(--dim);font-size:15px}
.intro{display:grid;gap:8px 36px;padding-block:12px 14px}
@media (min-width:1100px){.intro{grid-template-columns:repeat(2,minmax(0,64ch))}}
.intro p{margin:0;max-width:74ch}
.intro b{font-family:var(--disp);font-weight:600;color:var(--dim);letter-spacing:.01em}
.k-bad{color:var(--bad);font-weight:600}.k-warn{color:var(--warn);font-weight:600}
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:rgba(2,5,12,.92);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-block:1px solid var(--line)}
@media (max-width:620px){.bar{position:static}}
.barin{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding-block:9px}
.row2{padding-block:0 9px;gap:8px 22px}
.rowlab{font:500 10.5px var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.grp{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;font-size:12.5px;color:var(--dim)}
button{font:inherit;color:inherit}
.seg{display:flex;border:1px solid var(--line2);border-radius:8px;overflow:hidden}
.seg button{border:0;background:transparent;padding:6px 10px;font:500 12.5px var(--sans);color:var(--dim);cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--line)}
.seg button[aria-pressed="true"]{background:var(--panel2);color:var(--ink)}
@media (max-width:620px){#starts{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line);width:100%}
#starts button{background:var(--bg);border-left:0}#starts button[aria-pressed="true"]{background:var(--panel2)}}
.btn{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 11px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink);white-space:nowrap}
.btn.primary{border-color:rgba(62,230,255,.55)}
.btn:disabled{opacity:.45;cursor:default}
.chk{display:flex;align-items:center;gap:5px;cursor:pointer;white-space:nowrap}
.chk input{accent-color:var(--track);margin:0}
.chk span{font-variant-numeric:tabular-nums;color:var(--faint)}
.rng{display:flex;align-items:center;gap:6px;white-space:nowrap}
.rng input{width:96px;accent-color:var(--track)}
.rng output{font:500 12.5px var(--mono);color:var(--ink);min-width:3.2em}
#status{font:500 12.5px var(--mono);color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
#status b{color:var(--ink);font-weight:500}
.main{display:grid;gap:14px;padding-block:14px 8px;grid-template-columns:minmax(0,1fr)}
@media (min-width:1100px){.main{grid-template-columns:minmax(0,1fr) 380px}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;min-width:0}
.mapbox{position:relative;width:100%;aspect-ratio:16/10;max-height:78vh;min-height:320px}
@media (max-width:620px){.mapbox{aspect-ratio:1/1.05}}
.mapbox canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;border-radius:10px}
.maphint{position:absolute;left:10px;bottom:8px;font-size:11.5px;color:var(--faint);pointer-events:none}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;padding:10px 12px 12px;list-style:none;color:var(--dim);font-size:12.5px;border-top:1px solid var(--line)}
.legend li{display:flex;align-items:center;gap:7px}
.legend svg{width:24px;height:12px;flex:none}
.side{display:flex;flex-direction:column;gap:12px;padding:12px}
.side h2{font:600 15px/1.25 var(--disp);margin:0}
.score{width:100%;border-collapse:collapse;font-size:13px}
.score th,.score td{padding:5px 4px;border-bottom:1px solid var(--line);vertical-align:top}
.score tr:last-child td,.score tr:last-child th{border-bottom:0}
.score thead th{font:600 11px var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--faint);text-align:right}
.score thead th:first-child{text-align:left}
.score tbody th{text-align:left;font-weight:400;color:var(--dim)}
.score td{text-align:right;font:500 13px var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
.score td.bad{color:var(--bad)}.score td.warn{color:var(--warn)}.score td.ok{color:var(--ok)}
.score .of{color:var(--faint);font-size:11.5px}
.hist{display:flex;flex-direction:column;gap:4px}
.hist span{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.hist canvas{width:100%;height:140px;display:block}
.worst{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px;font-size:12.5px}
.worst li{display:flex;justify-content:space-between;gap:10px;color:var(--dim);cursor:pointer}
.worst li b{color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.worst li span{font:500 12.5px var(--mono);white-space:nowrap}
.note{margin:0;font-size:12.5px;color:var(--faint)}
.lift{display:grid;gap:14px;padding-block:20px 8px;grid-template-columns:minmax(0,1fr)}
@media (min-width:1100px){.lift{grid-template-columns:minmax(0,1fr) 380px}}
.lift h2,.after h2{font:600 19px/1.25 var(--disp);margin:0 0 4px}
.box3{position:relative;width:100%;aspect-ratio:16/10;max-height:72vh;min-height:300px}
@media (max-width:620px){.box3{aspect-ratio:1/1}}
.box3 canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;border-radius:10px}
.ro{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px;margin:0}
.ro div{min-width:0}
.ro dt{font:500 10.5px/1.3 var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.ro dd{margin:1px 0 0;font:500 14px/1.35 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.after{padding-block:22px 10px;display:grid;gap:10px}
.after h3{font:600 15.5px/1.3 var(--disp);margin:6px 0 0}
.after p{margin:0;max-width:74ch;color:var(--dim)}
.after p b{color:var(--ink);font-weight:600}
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px;max-width:980px}
table.res{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
.res th,.res td{padding:8px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
.res tr:last-child td,.res tr:last-child th{border-bottom:0}
.res thead th{font:600 12.5px var(--disp);color:var(--dim);background:var(--panel)}
.res tbody th{text-align:left;font-weight:500;color:var(--dim)}
.res td{font-family:var(--mono);font-variant-numeric:tabular-nums}
.method{display:grid;grid-template-columns:max-content minmax(0,74ch);gap:8px 20px;margin:0;color:var(--dim)}
.method dt{font:600 13.5px var(--disp);color:var(--ink);padding-top:1px}
.method dd{margin:0}
@media (max-width:620px){.method{grid-template-columns:minmax(0,1fr);gap:2px}.method dd{margin-bottom:10px}}
#dl{justify-self:start}
footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
#tip{position:fixed;z-index:30;pointer-events:none;background:rgba(6,12,24,.97);border:1px solid var(--line2);border-radius:8px;padding:7px 10px;font-size:12.5px;line-height:1.45;max-width:300px}
#tip b{font-weight:600}
#tip span{display:block;color:var(--dim)}
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
</style>
</head>
<body>
<header class="wrap top">
<h1>The Best Flat Map</h1>
<p class="sub">Every measured flight, ping, rail line and satellite pass on one plane</p>
</header>
<section class="wrap intro">
<p><b>Rule.</b> The surface is a plane. Each of the {{N_CITIES}} cities may sit anywhere on it. No latitude, longitude, radius, projection or spherical formula enters the solver.</p>
<p><b>Measurements.</b> Flight times give lengths: {{N_FLIGHTS}} routes, cruise speed × time in the air, both directions. Pings ({{N_RT}}), rail and road lengths ({{N_RAIL}}) and satellite passes ({{N_SAT}}) give limits: no farther apart than this.</p>
<p><b>Tolerance.</b> A flight holds between {{LO_PCT}}% and {{HI_PCT}}% of its length. Shorter means a route over a third longer than the straight line; longer means an aircraft above its top speed the whole way.</p>
<p><b>Solver.</b> From a starting flat map it moves the cities to keep as many measurements as it can and marks the rest <span class="k-bad">red</span> (faster than possible) or <span class="k-warn">amber</span> (far off the straight line). Coastlines follow the cities like a rubber sheet.</p>
</section>
<div class="bar">
<div class="wrap barin">
<span class="rowlab">Start from</span>
<div class="seg" role="group" aria-label="Starting map" id="starts">
<button type="button" data-start="ae" aria-pressed="true" title="Azimuthal equidistant map centred on the North Pole">North-pole AE</button>
<button type="button" data-start="dp" aria-pressed="false" title="Azimuthal equidistant map centred on 0° N 0° E">Dual-pole AE</button>
<button type="button" data-start="mds" aria-pressed="false" title="No existing map: the cities laid out from the flight times alone">No map</button>
<button type="button" data-start="best" aria-pressed="false" title="The best layout found offline from {{N_STARTS}} starting maps">Best of {{N_STARTS}}</button>
</div>
<button type="button" class="btn primary" id="run">Pause</button>
<button type="button" class="btn" id="reset">Restart</button>
<span id="status"></span>
</div>
<div class="wrap barin row2">
<span class="rowlab">Data</span>
<div class="grp">
<label class="chk"><input type="checkbox" id="d-fl" checked> Flight times <span>{{N_FLIGHTS}}</span></label>
<label class="chk"><input type="checkbox" id="d-rt" checked> Pings <span>{{N_RT}}</span></label>
<label class="chk"><input type="checkbox" id="d-rr" checked> Rail &amp; road <span>{{N_RAIL}}</span></label>
<label class="chk"><input type="checkbox" id="d-sat" checked> Satellite passes <span>{{N_SAT}}</span></label>
</div>
<div class="grp">
<label class="rng" for="lo">Flight distance from <input type="range" id="lo" min="40" max="95" step="5" value="{{LO_PCT}}"><output id="lov">{{LO_PCT}}%</output></label>
<label class="rng" for="hi">to <input type="range" id="hi" min="100" max="150" step="5" value="{{HI_PCT}}"><output id="hiv">{{HI_PCT}}%</output></label>
</div>
<div class="seg" role="group" aria-label="Signal speed for pings" id="light">
<button type="button" data-v="glass" aria-pressed="true" title="Pings travel at the speed of light in optical fibre">Pings at fibre speed</button>
<button type="button" data-v="vacuum" aria-pressed="false" title="Pings travel at the speed of light in vacuum (hollow-core fibre or better)">Pings at vacuum speed</button>
</div>
</div>
</div>
<section class="wrap main" aria-label="The plane">
<div class="panel">
<div class="mapbox"><canvas id="map" aria-label="The flat map the solver is building"></canvas><span class="maphint">Drag to pan · scroll to zoom · click a city</span></div>
<ul class="legend">
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2"/></svg>Flight longer than its aircraft can fly</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ffc94a" stroke-width="2"/></svg>Flight squeezed short</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2" stroke-dasharray="4 3"/></svg>Limit broken (signal faster than light, line longer than its track)</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#3dffa0" stroke-width="1.5"/></svg>Kept (selected city)</li>
</ul>
</div>
<aside class="panel side" aria-label="Score">
<h2>Broken measurements</h2>
<table class="score">
<thead><tr><th scope="col"></th><th scope="col">This plane</th><th scope="col">Globe</th></tr></thead>
<tbody id="score"></tbody>
</table>
<div class="hist"><span>Map distance ÷ timed length, every flight</span><canvas id="hist" aria-label="Histogram of map distance divided by timed length"></canvas></div>
<div><h2 style="margin-bottom:6px">Farthest out</h2><ul class="worst" id="worst"></ul></div>
<p class="note" id="globenote">Globe: the same rules applied to great-circle distances on a sphere of radius 6,371 km.</p>
</aside>
</section>
<section class="wrap lift" aria-label="Without the plane">
<div class="panel">
<div class="box3"><canvas id="c3" aria-label="The same measurements laid out in free 3D space"></canvas><span class="maphint">Drag to turn</span></div>
</div>
<aside class="panel side">
<h2>Lift the rule</h2>
<p class="note" style="color:var(--dim)">Same cities, same measurements, same solver. The points may now leave the plane; nothing tells them what shape to take. It starts from the flat map above with each city nudged a few kilometres up or down.</p>
<div class="grp"><button type="button" class="btn primary" id="lift">Release into 3D</button><button type="button" class="btn" id="spin" aria-pressed="true">Turning</button></div>
<dl class="ro">
<div><dt>Broken</dt><dd id="r3b">–</dd></div>
<div><dt>Stage</dt><dd id="r3s">–</dd></div>
<div><dt>Wraps round its centre</dt><dd id="r3w">–</dd></div>
<div><dt>Best-fit sphere radius</dt><dd id="r3r">–</dd></div>
<div><dt>Median distance off it</dt><dd id="r3o">–</dd></div>
<div><dt>Broken on the plane</dt><dd id="r3f">–</dd></div>
</dl>
<p class="note">The sphere is fitted after the solve to describe the shape and plays no part in it. Lengths may land anywhere inside their tolerance, so its radius is only loosely set.</p>
</aside>
</section>
<section class="wrap after">
<h2>Result</h2>
<p>Best of {{N_STARTS}} starting maps (North-pole and dual-pole AE, the layout of the flight times alone, and AE maps centred on {{N_STARTS_AE}} other points): <b>{{BEST_BROKEN}} measurements broken</b>. The plane keeps almost the whole northern network, but it cannot close the ring of routes around the southern oceans: {{LONG_LIST}}. Each of those needs its aircraft faster than it can fly. To keep the rest, the plane squeezes {{BEST_B80}} flights below 80% of their timed length; on the globe {{GLOBE_B80}} are.</p>
<p>On the globe, by the same rules: {{GLOBE_LONG}} flight longer than its aircraft can fly (San Salvador–San José, a short hop where the 15-minute allowance for climb and descent is too generous), {{GLOBE_LIM}} limits broken, and {{GLOBE_SHORT}} flights below {{LO_PCT}}%: detours around closed airspace, indirect airways, and short hops where climb and approach take most of the time ({{GLOBE_SHORT_LIST}}). Surveyed triangles do not depend on the layout: a plane gives every triangle exactly 180°, and {{TRI_FLAT}} of the {{N_TRI}} measured triangles exceed that by more than four times their survey's angle noise ({{TRI_GLOBE}} on the globe; <a href="https://distance.aethercosmology.com/#triangles">The Triangle Test</a>).</p>
<p>Released into 3D, the same cities and measurements close into a ball. The cities wrap {{WRAP3}}° round its centre, with {{BROKEN3}} measurements broken.</p>
<h3>Wider tolerances</h3>
<p>The further short a flight may fall, the more of the network the plane can squeeze to make room for the southern routes. It keeps everything once flights may fall to {{FIT_LO}}% of their timed length, which means routes up to twice as long as its straight lines. That layout has {{FIT_B75}} flights below 75% and {{FIT_B60}} below 60%. The globe has {{GLOBE_B75}} and {{GLOBE_B60}}.</p>
<div class="tw"><table class="res" id="bands"><thead><tr><th scope="col">Flights kept between</th><th scope="col">Plane: too long</th><th scope="col">Plane: too short</th><th scope="col">Plane: limits</th><th scope="col">Plane: below 75%</th><th scope="col">Globe: too long</th><th scope="col">Globe: too short</th><th scope="col">Globe: limits</th></tr></thead><tbody></tbody></table></div>
<h3>Pings alone</h3>
<p>A ping only caps a distance, and internet routes run well past the straight line, so the caps are loose. With pings alone a flat map can shrink until every cap holds; turn off Flight times to see it. Scale comes from lengths, and the flights supply them.</p>
<h2 style="margin-top:14px">Method</h2>
<dl class="method">
<dt>Flights</dt><dd>From <a href="https://distance.aethercosmology.com/#sphere">The Measured Sphere</a> ("each flight's own speed"): routes flown both ways, each direction's median time from wheels-off to wheels-on over two weeks (FlightAware), less 15 minutes for climb and descent, times the aircraft type's cruise speed (Mach × 1,062 km/h). Averaging the two directions cancels the wind. Click a city for each route's flights, aircraft and track logs.</dd>
<dt>Limits</dt><dd>A ping is a round trip: the signal goes out and comes back. A ping of <i>t</i> ms caps a distance at {{KM_PER_MS}} km per ms (light in fibre; the vacuum option multiplies by 1.468), plus 25 km at each end and the distance from each server to its city's airport. Sources: WonderNetwork's 2020 archive and the live RIPE Atlas, Globalping and Vultr measurements on <a href="https://pingstats.aethercosmology.com/">pingstats</a>. {{N_DROPPED}} pings shorter than every surface allows were left out as mislocated servers. Rail and road: the straight line can be no longer than the published route. Satellite passes: two stations can be no farther apart than the two closest ranges plus the satellite's measured flight between them.</dd>
<dt>Solver</dt><dd>First a least-squares fit of every flight's relative error. Then eight rounds of a reweighted, nearly linear penalty on how far each measurement falls outside its band, which gives up on as few measurements as it can instead of straining many (the layout with the fewest broken is kept). Limits count three times a flight. L-BFGS throughout. The page runs the same steps live. Offline, the same code (<code>fit.py</code>) ran from {{N_STARTS}} starting maps and then refined the 20 best layouts with twelve rounds.</dd>
<dt>Drawing</dt><dd>Coastlines (Natural Earth 1:110m) are projected on the starting map, then carried along by a thin-plate spline through the cities' starting and current positions. Land near the starting map's rim is outlined only.</dd>
<dt>3D</dt><dd>The same objective with a third coordinate. After the solve, a least-squares sphere is fitted to the points to describe the shape; how far round it reaches is the angle from the cities' mean direction to the farthest city.</dd>
</dl>
<button type="button" class="btn" id="dl">Download data (JSON)</button>
</section>
<footer class="wrap">Built {{BUILT}}. <a href="https://github.com/aetherbird/cable-latency-survey">Code and data</a> (MIT, CC BY 4.0) · <a href="https://github.com/aetherbird/cable-latency-survey/blob/main/CONTRIBUTING.md">Contribute</a> · Part of <a href="https://distance.aethercosmology.com/">The Shape of Distance</a>.</footer>
<div id="tip" hidden></div>
<script>
"use strict";
const D = /*DATA*/null;
const N = D.cities.length, R = 6371.0088, D2R = Math.PI / 180;
const $ = s => document.querySelector(s);
const fmt = n => Math.round(n).toLocaleString("en-US");
const css = getComputedStyle(document.documentElement);
const COL = { bad: css.getPropertyValue("--bad").trim(), warn: css.getPropertyValue("--warn").trim(), ok: css.getPropertyValue("--ok").trim(),
land: css.getPropertyValue("--land").trim(), coast: css.getPropertyValue("--coast").trim(), ink: css.getPropertyValue("--ink").trim(),
dim: css.getPropertyValue("--dim").trim(), faint: css.getPropertyValue("--faint").trim(), track: css.getPropertyValue("--track").trim() };
/* ---------------- measurements ---------------- */
const FL = D.fl.map((f, i) => ({ i, a: f[0], b: f[1], L: f[2], airline: f[3], fa: f[4], fb: f[5], page: f[6], ab: f[7], ba: f[8], g: D.gf[i] }));
const LIM = D.lim.map((l, i) => ({ i, a: l[0], b: l[1], U: l[2], kind: l[3], note: l[4], label: l[5], url: l[6], g: D.gl[i] }));
const KIND = { rt: "Ping", rail: "Rail", road: "Road", sat: "Satellite pass" };
const cityName = i => D.cities[i][0];
const S = { fl: true, rt: true, rr: true, sat: true, lo: D.stats.lo, hi: D.stats.hi, vac: false, start: "ae" };
let act = null; // active measurement arrays
function buildActive() {
const f = S.fl ? FL : [];
const l = LIM.filter(x => (x.kind === "rt" && S.rt) || ((x.kind === "rail" || x.kind === "road") && S.rr) || (x.kind === "sat" && S.sat));
act = { f, l, fa: Int32Array.from(f, x => x.a), fb: Int32Array.from(f, x => x.b), fL: Float64Array.from(f, x => x.L),
la: Int32Array.from(l, x => x.a), lb: Int32Array.from(l, x => x.b), lU: Float64Array.from(l, x => x.kind === "rt" && S.vac ? x.U * 1.4682 : x.U) };
}
/* ---------------- solver (mirrors fit.py) ---------------- */
const LIMIT_W = 3, MARGIN = 0.005, DELTA = 0.003, EPS = 0.02, ROUNDS = 8;
function makeSolver(dim, X0) {
const n = N * dim;
const sv = { dim, X: Float64Array.from(X0), stage: 0, it: 0, total: 0, w: new Float64Array(act.f.length).fill(1),
wl: new Float64Array(act.l.length).fill(LIMIT_W), mem: [], g: null, F: null, done: false, best: null, bestK: Infinity, hist: [] };
sv.evalF = (X, G) => {
G.fill(0); let F = 0;
const { fa, fb, fL, la, lb, lU } = act; const lo = S.lo, hi = S.hi;
for (let k = 0; k < fa.length; k++) {
const a = fa[k] * dim, b = fb[k] * dim; let dd = 1e-9;
for (let q = 0; q < dim; q++) { const t = X[a + q] - X[b + q]; dd += t * t; }
dd = Math.sqrt(dd); let ga;
if (sv.stage === 0) { const e = dd / fL[k] - 1; F += e * e; ga = 2 * e / fL[k] / dd; }
else {
const A = fL[k] * (1 - lo + MARGIN), B = fL[k] * (1 + hi - MARGIN);
const eh = Math.max(0, dd / B - 1), el = Math.max(0, 1 - dd / A);
const sh = Math.sqrt(eh * eh + DELTA * DELTA), sl = Math.sqrt(el * el + DELTA * DELTA);
F += sv.w[k] * (sh + sl - 2 * DELTA); ga = sv.w[k] * (eh / sh / B - el / sl / A) / dd;
}
for (let q = 0; q < dim; q++) { const t = ga * (X[a + q] - X[b + q]); G[a + q] += t; G[b + q] -= t; }
}
for (let k = 0; k < la.length; k++) {
const a = la[k] * dim, b = lb[k] * dim; let dd = 1e-9;
for (let q = 0; q < dim; q++) { const t = X[a + q] - X[b + q]; dd += t * t; }
dd = Math.sqrt(dd); let gl;
if (sv.stage === 0) { const ex = Math.max(0, dd / lU[k] - 1); if (!ex) continue; F += 100 * ex * ex; gl = 200 * ex / lU[k] / dd; }
else {
const ub = lU[k] * (1 - MARGIN), ex = Math.max(0, dd / ub - 1); if (!ex) continue;
const sx = Math.sqrt(ex * ex + DELTA * DELTA); F += sv.wl[k] * (sx - DELTA); gl = sv.wl[k] * ex / sx / ub / dd;
}
for (let q = 0; q < dim; q++) { const t = gl * (X[a + q] - X[b + q]); G[a + q] += t; G[b + q] -= t; }
}
return F;
};
sv.G = new Float64Array(n); sv.F = sv.evalF(sv.X, sv.G);
sv.Xn = new Float64Array(n); sv.Gn = new Float64Array(n); sv.d = new Float64Array(n);
sv.stall = 0;
return sv;
}
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
function lbfgsStep(sv) {
const { X, G, d, Xn, Gn, mem } = sv; const n = X.length;
for (let i = 0; i < n; i++) d[i] = -G[i];
const al = [];
for (let j = mem.length - 1; j >= 0; j--) { const m = mem[j]; const a = m.rho * dot(m.s, d); al[j] = a; for (let i = 0; i < n; i++) d[i] -= a * m.y[i]; }
let gam;
if (mem.length) { const m = mem[mem.length - 1]; gam = dot(m.s, m.y) / dot(m.y, m.y); }
else { let gm = 0; for (let i = 0; i < n; i++) gm = Math.max(gm, Math.abs(G[i])); gam = gm > 0 ? 200 / gm : 1; }
for (let i = 0; i < n; i++) d[i] *= gam;
for (let j = 0; j < mem.length; j++) { const m = mem[j]; const b = m.rho * dot(m.y, d); for (let i = 0; i < n; i++) d[i] += m.s[i] * (al[j] - b); }
let gd = dot(G, d);
if (!(gd < 0)) { mem.length = 0; for (let i = 0; i < n; i++) d[i] = -G[i] * gam; gd = dot(G, d); if (!(gd < 0)) return false; }
let t = 1, Fn = 0;
for (let k = 0; k < 40; k++) {
for (let i = 0; i < n; i++) Xn[i] = X[i] + t * d[i];
Fn = sv.evalF(Xn, Gn);
if (Fn <= sv.F + 1e-4 * t * gd) break;
t *= 0.5; if (k === 39) return false;
}
const s = new Float64Array(n), y = new Float64Array(n);
for (let i = 0; i < n; i++) { s[i] = Xn[i] - X[i]; y[i] = Gn[i] - G[i]; }
const sy = dot(s, y);
if (sy > 1e-12) { mem.push({ s, y, rho: 1 / sy }); if (mem.length > 12) mem.shift(); }
const dec = sv.F - Fn;
X.set(Xn); G.set(Gn); sv.F = Fn; sv.it++; sv.total++;
sv.stall = dec <= 1e-9 * Math.max(1e-12, Math.abs(Fn)) ? sv.stall + 1 : 0;
return true;
}
function excess(sv) {
const { dim, X } = sv; const e = new Float64Array(act.f.length), l = new Float64Array(act.l.length);
for (let k = 0; k < act.fa.length; k++) { const r = dist(X, dim, act.fa[k], act.fb[k]) / act.fL[k]; e[k] = Math.max(0, r / (1 + S.hi) - 1) + Math.max(0, 1 - r / (1 - S.lo)); }
for (let k = 0; k < act.la.length; k++) l[k] = Math.max(0, dist(X, dim, act.la[k], act.lb[k]) / act.lU[k] - 1);
return { e, l };
}
function nBroken(sv) { const { e, l } = excess(sv); let k = 0; for (const v of e) if (v > 0) k++; for (const v of l) if (v > 5e-4) k++; return k; }
function nextStage(sv) {
const k = nBroken(sv);
if (k < sv.bestK) { sv.bestK = k; sv.best = Float64Array.from(sv.X); }
if (sv.stage >= ROUNDS) { sv.done = true; if (sv.best) sv.X.set(sv.best); sv.F = sv.evalF(sv.X, sv.G); return; }
if (sv.stage > 0) { const { e, l } = excess(sv); for (let i = 0; i < e.length; i++) sv.w[i] = EPS / (e[i] + EPS); for (let i = 0; i < l.length; i++) sv.wl[i] = LIMIT_W * EPS / (l[i] + EPS); }
sv.stage++; sv.it = 0; sv.mem.length = 0; sv.stall = 0; sv.F = sv.evalF(sv.X, sv.G);
}
function work(sv, ms, maxIt) {
const t0 = performance.now(); let k = 0;
while (!sv.done && performance.now() - t0 < ms && k < maxIt) {
const ok = lbfgsStep(sv); k++;
if (!ok || sv.stall > 25 || sv.it > (sv.stage ? 2500 : 4000)) nextStage(sv);
}
}
function dist(X, dim, a, b) { let s = 0; for (let q = 0; q < dim; q++) { const t = X[a * dim + q] - X[b * dim + q]; s += t * t; } return Math.sqrt(s); }
const STAGE = s => s === 0 ? "fitting lengths" : `round ${s} of ${ROUNDS}`;
/* ---------------- maps and rubber-sheet coastlines ---------------- */
function aeq(lat, lon, lat0, lon0) {
const p1 = lat * D2R, l1 = lon * D2R, p0 = lat0 * D2R, l0 = lon0 * D2R;
const cc = Math.sin(p0) * Math.sin(p1) + Math.cos(p0) * Math.cos(p1) * Math.cos(l1 - l0);
const c = Math.acos(Math.max(-1, Math.min(1, cc))); const k = c < 1e-9 ? 1 : c / Math.sin(c);
return [R * k * Math.cos(p1) * Math.sin(l1 - l0), R * k * (Math.cos(p0) * Math.sin(p1) - Math.sin(p0) * Math.cos(p1) * Math.cos(l1 - l0)), c];
}
function invert(A, n) { // Gauss-Jordan, returns inverse of n x n (row-major Float64Array)
const M = new Float64Array(n * 2 * n);
for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) M[i * 2 * n + j] = A[i * n + j]; M[i * 2 * n + n + i] = 1; }
for (let c = 0; c < n; c++) {
let p = c; for (let r = c + 1; r < n; r++) if (Math.abs(M[r * 2 * n + c]) > Math.abs(M[p * 2 * n + c])) p = r;
if (p !== c) for (let j = 0; j < 2 * n; j++) { const t = M[c * 2 * n + j]; M[c * 2 * n + j] = M[p * 2 * n + j]; M[p * 2 * n + j] = t; }
const v = M[c * 2 * n + c]; for (let j = 0; j < 2 * n; j++) M[c * 2 * n + j] /= v;
for (let r = 0; r < n; r++) { if (r === c) continue; const f = M[r * 2 * n + c]; if (!f) continue; for (let j = 0; j < 2 * n; j++) M[r * 2 * n + j] -= f * M[c * 2 * n + j]; }
}
const out = new Float64Array(n * n); for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) out[i * n + j] = M[i * 2 * n + n + j]; return out;
}
const tpsU = r2 => r2 > 1e-12 ? 0.5 * r2 * Math.log(r2) : 0; // r^2 log r, in 1000-km units
let SHEET = null; // {src, Ainv, K, M, rings}
function buildSheet(centre, srcX) {
const n = N, m = n + 3, U = 1000;
const sx = new Float64Array(n), sy = new Float64Array(n);
for (let i = 0; i < n; i++) { sx[i] = srcX[i][0] / U; sy[i] = srcX[i][1] / U; }
const A = new Float64Array(m * m);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) { const dx = sx[i] - sx[j], dy = sy[i] - sy[j]; A[i * m + j] = tpsU(dx * dx + dy * dy); }
A[i * m + i] += 1e-6;
A[i * m + n] = A[n * m + i] = 1; A[i * m + n + 1] = A[(n + 1) * m + i] = sx[i]; A[i * m + n + 2] = A[(n + 2) * m + i] = sy[i];
}
const Ainv = invert(A, m);
// coastline points in source coordinates; rings near the starting map's rim are outlined only
const pts = [], rings = [];
const anti = [-centre[0], centre[1] + 180]; // the starting map's rim: the point opposite its centre
for (const poly of D.land) for (const ring of poly) {
const idx = []; let wind = 0, prev = null;
for (const [lon, lat] of ring) {
const a = aeq(lat, lon, anti[0], anti[1]), t = Math.atan2(a[1], a[0]);
if (prev !== null) { let d = t - prev; if (d > Math.PI) d -= 2 * Math.PI; if (d < -Math.PI) d += 2 * Math.PI; wind += d; } prev = t;
const p = aeq(lat, lon, centre[0], centre[1]); if (p[2] > 168 * D2R) continue;
idx.push(pts.length / 2); pts.push(p[0] / U, p[1] / U);
}
// A coast that separates the centre from the rim point bounds either land around the centre (fill) or land
// around the rim point, which cannot be laid flat as one piece (outline). Land is the smaller side: measure
// the side holding the centre on an equal-area map.
let fill = true;
if (Math.abs(wind) >= Math.PI) {
let A = 0, q0 = null;
const p0 = centre[0] * D2R, l0 = centre[1] * D2R;
for (const [lon, lat] of ring) {
const p1 = lat * D2R, l1 = lon * D2R, cc = Math.sin(p0) * Math.sin(p1) + Math.cos(p0) * Math.cos(p1) * Math.cos(l1 - l0), k = Math.sqrt(2 / Math.max(1e-9, 1 + cc));
const q = [k * Math.cos(p1) * Math.sin(l1 - l0), k * (Math.cos(p0) * Math.sin(p1) - Math.sin(p0) * Math.cos(p1) * Math.cos(l1 - l0))];
if (q0) A += q0[0] * q[1] - q[0] * q0[1]; q0 = q;
}
fill = Math.abs(A / 2) < 2 * Math.PI; // unit sphere: half its area is 2 pi
}
if (idx.length > 2) rings.push({ idx, fill });
}
const M = pts.length / 2, K = new Float32Array(M * m), near = new Uint8Array(M);
for (let p = 0; p < M; p++) {
const x = pts[2 * p], y = pts[2 * p + 1];
let dmin = Infinity; for (let j = 0; j < n; j++) dmin = Math.min(dmin, (x - sx[j]) ** 2 + (y - sy[j]) ** 2);
near[p] = dmin < 1.6 * 1.6 ? 1 : 0; // within 1,600 km of a city on the starting map
for (let j = 0; j < n; j++) { const dx = x - sx[j], dy = y - sy[j]; K[p * m + j] = tpsU(dx * dx + dy * dy); }
K[p * m + n] = 1; K[p * m + n + 1] = x; K[p * m + n + 2] = y;
}
SHEET = { Ainv, K, M, m, rings, near, out2: new Float32Array(M * 2), out3: new Float32Array(M * 3) };
}
function warp(X, dim) { // thin-plate spline through (source -> X), evaluated at every coastline point
const { Ainv, K, M, m } = SHEET; const n = N; const W = new Float64Array(m * dim);
for (let r = 0; r < m; r++) for (let q = 0; q < dim; q++) { let s = 0; for (let j = 0; j < n; j++) s += Ainv[r * m + j] * X[j * dim + q]; W[r * dim + q] = s; }
const out = dim === 2 ? SHEET.out2 : SHEET.out3;
for (let p = 0; p < M; p++) for (let q = 0; q < dim; q++) { let s = 0; const o = p * m; for (let r = 0; r < m; r++) s += K[o + r] * W[r * dim + q]; out[p * dim + q] = s; }
return out;
}
/* ---------------- state ---------------- */
let sv = null, running = true, sel = -1, view = null, userView = false, coast = null, coastAt = -1;
function startPositions(key) {
if (key === "best") return { centre: D.best.centre, X: D.best.X, label: D.best.label };
const s = D.starts[key]; return { centre: s.centre, X: s.X, label: s.label };
}
function restart() {
buildActive();
const st = startPositions(S.start);
// the drawing domain: the cities on the map the coastlines are projected from (AE on the start's centre;
// the flight-times-only start is drawn from North-pole AE)
const src = D.cities.map(c => aeq(c[1], c[2], st.centre[0], st.centre[1]));
if (!SHEET || SHEET.key !== S.start) { buildSheet(st.centre, src); SHEET.key = S.start; }
sv = makeSolver(2, st.X.flat());
if (S.start === "best") { sv.done = true; sv.stage = ROUNDS; }
running = !sv.done; userView = false; view = null; coastAt = -1;
$("#run").textContent = running ? "Pause" : "Solve";
update(true);
}
function continueSolve() { // data or tolerance changed: keep the current layout and solve again
if (!sv) return restart();
buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); running = true; $("#run").textContent = "Pause"; update(true);
}
/* ---------------- scoring ---------------- */
function score(X, dim) {
const out = { long: [], short: [], rt: [], rr: [], sat: [], ratio: new Float64Array(FL.length).fill(NaN), lim: new Map() };
if (S.fl) for (const f of FL) {
const r = dist(X, dim, f.a, f.b) / f.L; out.ratio[f.i] = r;
if (r > 1 + S.hi) out.long.push([f, r]); else if (r < 1 - S.lo) out.short.push([f, r]);
}
for (const l of act.l) {
const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; const r = dist(X, dim, l.a, l.b) / U;
if (r > 1.0005) { const k = l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"; out[k].push([l, r]); out.lim.set(l.i, r); }
}
return out;
}
function globeScore() {
const o = { long: 0, short: 0, rt: 0, rr: 0, sat: 0 };
if (S.fl) for (const f of FL) { const r = f.g / f.L; if (r > 1 + S.hi) o.long++; else if (r < 1 - S.lo) o.short++; }
for (const l of act.l) { const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; if (l.g > U * 1.0005) o[l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"]++; }
return o;
}
let SC = null, GS = null;
function update(full) {
SC = score(sv.X, 2); if (full) GS = globeScore();
const nAct = { rt: act.l.filter(l => l.kind === "rt").length, rr: act.l.filter(l => l.kind === "rail" || l.kind === "road").length, sat: act.l.filter(l => l.kind === "sat").length };
const row = (label, a, b, n, cls) => `<tr><th scope="row">${label}</th><td class="${a ? cls : "ok"}">${fmt(a)}<span class="of"> / ${fmt(n)}</span></td><td class="${b ? cls : "ok"}">${fmt(b)}</td></tr>`;
const nf = S.fl ? FL.length : 0;
$("#score").innerHTML =
row("Flights longer than the aircraft can fly", SC.long.length, GS.long, nf, "bad") +
row(`Flights below ${Math.round((1 - S.lo) * 100)}% of their length`, SC.short.length, GS.short, nf, "warn") +
row(`Pings faster than light in ${S.vac ? "vacuum" : "fibre"}`, SC.rt.length, GS.rt, nAct.rt, "bad") +
row("Rail and road shorter than the straight line", SC.rr.length, GS.rr, nAct.rr, "bad") +
row("Satellite passes", SC.sat.length, GS.sat, nAct.sat, "bad") +
`<tr><th scope="row">Surveyed triangles (any plane)</th><td class="bad">${fmt(D.stats.tri.flat)}<span class="of"> / ${fmt(D.stats.tri.n)}</span></td><td class="${D.stats.tri.globe ? "bad" : "ok"}">${fmt(D.stats.tri.globe)}</td></tr>`;
const worst = [...SC.long.map(x => ["f", x[0], x[1]]), ...[...SC.rt, ...SC.rr, ...SC.sat].map(x => ["l", x[0], x[1]])].sort((p, q) => q[2] - p[2]).slice(0, 6);
$("#worst").innerHTML = worst.length ? worst.map(([t, x, r]) => `<li data-a="${x.a}" data-b="${x.b}"><b>${cityName(x.a)} – ${cityName(x.b)}</b><span style="color:${COL.bad}">${t === "f" ? "×" + r.toFixed(2) + " top speed" : "×" + r.toFixed(2) + " " + (x.kind === "rt" ? "light" : "limit")}</span></li>`).join("")
: `<li><b>Nothing faster than possible</b><span></span></li>`;
drawHist();
}
/* ---------------- 2D drawing ---------------- */
const cv = $("#map"), g = cv.getContext("2d");
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = cv.getBoundingClientRect(); W = r.width; H = r.height; cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
const r3 = c3.getBoundingClientRect(); W3 = r3.width; H3 = r3.height; c3.width = Math.round(W3 * DPR); c3.height = Math.round(H3 * DPR);
const rh = hc.getBoundingClientRect(); hc.width = Math.round(rh.width * DPR); hc.height = Math.round(rh.height * DPR);
drawHist();
}
function fitView(X) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (let i = 0; i < N; i++) { const x = X[2 * i], y = X[2 * i + 1]; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
const pad = 0.08, s = Math.min(W / ((x1 - x0) * (1 + 2 * pad) || 1), H / ((y1 - y0) * (1 + 2 * pad) || 1));
return { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, s };
}
const P = (x, y) => [W / 2 + (x - view.cx) * view.s, H / 2 - (y - view.cy) * view.s];
function drawMap() {
if (!W) return;
const X = sv.X;
const target = fitView(X);
if (!view) view = { ...target };
else if (!userView) { const k = 0.12; view.cx += (target.cx - view.cx) * k; view.cy += (target.cy - view.cy) * k; view.s *= Math.pow(target.s / view.s, k); }
g.setTransform(DPR, 0, 0, DPR, 0, 0); g.clearRect(0, 0, W, H);
g.fillStyle = "#040a16"; g.fillRect(0, 0, W, H);
// coastlines
if (coastAt !== sv.total) { coast = warp(X, 2); coastAt = sv.total; }
g.lineJoin = "round";
for (const r of SHEET.rings) {
g.beginPath();
r.idx.forEach((p, k) => { const q = P(coast[2 * p], coast[2 * p + 1]); k ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1]); });
if (r.fill) { g.closePath(); g.fillStyle = COL.land; g.fill(); g.strokeStyle = COL.coast; g.lineWidth = 0.8; g.stroke(); }
else { g.strokeStyle = "rgba(58,90,128,.55)"; g.lineWidth = 0.8; g.setLineDash([3, 3]); g.stroke(); g.setLineDash([]); }
}
// selected city: its kept measurements
if (sel >= 0) {
g.lineWidth = 1; g.strokeStyle = "rgba(61,255,160,.55)";
for (const f of act.f) if ((f.a === sel || f.b === sel)) { const r = SC.ratio[f.i]; if (r <= 1 + S.hi && r >= 1 - S.lo) seg(X, f.a, f.b); }
}
// broken measurements
g.lineWidth = 1.6;
g.strokeStyle = COL.warn; for (const [f] of SC.short) if (sel < 0 || f.a === sel || f.b === sel) seg(X, f.a, f.b);
g.setLineDash([5, 4]); g.strokeStyle = COL.bad;
for (const k of ["rt", "rr", "sat"]) for (const [l] of SC[k]) if (sel < 0 || l.a === sel || l.b === sel) seg(X, l.a, l.b);
g.setLineDash([]); g.lineWidth = 2; g.strokeStyle = COL.bad;
for (const [f] of SC.long) if (sel < 0 || f.a === sel || f.b === sel) seg(X, f.a, f.b);
// cities
for (let i = 0; i < N; i++) {
const q = P(X[2 * i], X[2 * i + 1]);
g.beginPath(); g.arc(q[0], q[1], i === sel ? 4.5 : 2.4, 0, 7); g.fillStyle = i === sel ? COL.track : COL.city || "#fff"; g.fill();
}
if (sel >= 0) { const q = P(X[2 * sel], X[2 * sel + 1]); label(q, cityName(sel)); }
for (const [f] of SC.long.slice().sort((p, q) => q[1] - p[1]).slice(0, 4)) for (const i of [f.a, f.b]) if (i !== sel) label(P(X[2 * i], X[2 * i + 1]), cityName(i), true);
}
function seg(X, a, b) { const p = P(X[2 * a], X[2 * a + 1]), q = P(X[2 * b], X[2 * b + 1]); g.beginPath(); g.moveTo(p[0], p[1]); g.lineTo(q[0], q[1]); g.stroke(); }
function label(q, t, small) {
g.font = `${small ? 500 : 600} ${small ? 11 : 12.5}px Inter, system-ui, sans-serif`; g.textBaseline = "middle";
g.lineWidth = 3; g.strokeStyle = "rgba(4,10,22,.9)"; g.strokeText(t, q[0] + 7, q[1] - 7); g.fillStyle = small ? COL.dim : COL.ink; g.fillText(t, q[0] + 7, q[1] - 7);
}
/* ---------------- histogram ---------------- */
const hc = $("#hist"), hg = hc.getContext("2d");
function drawHist() {
if (!sv || !hc.width) return;
const w = hc.width / DPR, h = hc.height / DPR; hg.setTransform(DPR, 0, 0, DPR, 0, 0); hg.clearRect(0, 0, w, h);
const x0 = 0.4, x1 = 2.1, nb = 85, bw = (x1 - x0) / nb, X = v => 4 + (v - x0) / (x1 - x0) * (w - 8);
const flat = new Array(nb).fill(0), glob = new Array(nb).fill(0);
for (const f of FL) { const r = SC.ratio[f.i]; if (r === r) flat[Math.max(0, Math.min(nb - 1, Math.floor((r - x0) / bw)))]++; glob[Math.max(0, Math.min(nb - 1, Math.floor((f.g / f.L - x0) / bw)))]++; }
const top = Math.max(...flat, ...glob, 1), base = h - 16, Y = v => base - v / top * (base - 6);
hg.fillStyle = "rgba(61,255,160,.07)"; hg.fillRect(X(1 - S.lo), 0, X(1 + S.hi) - X(1 - S.lo), base);
for (let k = 0; k < nb; k++) {
const v = flat[k]; if (!v) continue; const c = x0 + (k + 0.5) * bw;
hg.fillStyle = c > 1 + S.hi ? COL.bad : c < 1 - S.lo ? COL.warn : "rgba(232,240,251,.75)";
hg.fillRect(X(x0 + k * bw) + 0.5, Y(v), Math.max(1, X(x0 + (k + 1) * bw) - X(x0 + k * bw) - 1), base - Y(v));
}
hg.beginPath(); hg.strokeStyle = COL.track; hg.lineWidth = 1.4;
for (let k = 0; k < nb; k++) { const xa = X(x0 + k * bw), xb = X(x0 + (k + 1) * bw); k ? hg.lineTo(xa, Y(glob[k])) : hg.moveTo(xa, Y(glob[k])); hg.lineTo(xb, Y(glob[k])); }
hg.stroke();
hg.fillStyle = COL.faint; hg.font = "500 10.5px JetBrains Mono, monospace"; hg.textBaseline = "alphabetic";
for (const v of [0.5, 0.75, 1, 1.25, 1.5, 2]) { hg.fillText(v === 1 ? "1" : String(v), X(v) - (v === 1 ? 3 : 9), h - 3); hg.fillRect(X(v), base, 1, 3); }
hg.font = "500 11px Inter, sans-serif"; hg.fillStyle = COL.dim; hg.fillText("plane", w - 100, 12); hg.fillStyle = COL.track; hg.fillText("globe", w - 52, 12);
}
/* ---------------- interaction ---------------- */
const tip = $("#tip");
function nearestCity(mx, my) {
let best = -1, bd = 12 * 12;
for (let i = 0; i < N; i++) { const q = P(sv.X[2 * i], sv.X[2 * i + 1]); const d = (q[0] - mx) ** 2 + (q[1] - my) ** 2; if (d < bd) { bd = d; best = i; } }
return best;
}
function nearestLine(mx, my) {
let best = null, bd = 6 * 6;
const test = (x, t, r) => {
if (sel >= 0 && x.a !== sel && x.b !== sel) return;
const p = P(sv.X[2 * x.a], sv.X[2 * x.a + 1]), q = P(sv.X[2 * x.b], sv.X[2 * x.b + 1]);
const dx = q[0] - p[0], dy = q[1] - p[1], L2 = dx * dx + dy * dy; const u = L2 ? Math.max(0, Math.min(1, ((mx - p[0]) * dx + (my - p[1]) * dy) / L2)) : 0;
const d = (p[0] + u * dx - mx) ** 2 + (p[1] + u * dy - my) ** 2; if (d < bd) { bd = d; best = [t, x, r]; }
};
for (const [f, r] of SC.long) test(f, "f", r);
for (const [f, r] of SC.short) test(f, "f", r);
for (const k of ["rt", "rr", "sat"]) for (const [l, r] of SC[k]) test(l, "l", r);
if (sel >= 0) for (const f of act.f) if (f.a === sel || f.b === sel) test(f, "f", SC.ratio[f.i]);
return best;
}
const hm = m => `${Math.floor(m / 60)}h ${String(Math.round(m % 60)).padStart(2, "0")}m`;
function flightTip(f, r, planeKm) {
const sp = (f.ab[4] + f.ba[4]) / 2;
const state = r > 1 + S.hi ? `<b style="color:${COL.bad}">needs ${fmt(sp * r)} km/h, ×${r.toFixed(2)} its cruise speed</b>` : r < 1 - S.lo ? `<b style="color:${COL.warn}">route ${Math.round((1 / r - 1) * 100)}% longer than the straight line</b>` : `<b style="color:${COL.ok}">kept</b>`;
return `<b>${cityName(f.a)} – ${cityName(f.b)}</b><span>${f.airline} ${f.fa}/${f.fb} · ${f.ab[1]}/${f.ba[1]}</span><span>In the air ${hm(f.ab[2])} / ${hm(f.ba[2])} · length ${fmt(f.L)} km</span><span>This plane ${fmt(planeKm)} km · globe ${fmt(f.g)} km</span>${state}`;
}
function limTip(l, r, planeKm) {
return `<b>${cityName(l.a)} – ${cityName(l.b)}</b><span>${KIND[l.kind]}: ${l.note}</span>${l.label ? `<span>${l.label}</span>` : ""}<span>Limit ${fmt(l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U)} km · this plane ${fmt(planeKm)} km · globe ${fmt(l.g)} km</span>${r > 1.0005 ? `<b style="color:${COL.bad}">broken ×${r.toFixed(2)}</b>` : ""}`;
}
function showTip(html, x, y) { tip.innerHTML = html; tip.hidden = false; const r = tip.getBoundingClientRect(); tip.style.left = Math.min(innerWidth - r.width - 8, x + 14) + "px"; tip.style.top = Math.min(innerHeight - r.height - 8, y + 14) + "px"; }
let drag = null;
cv.addEventListener("pointerdown", e => { drag = { x: e.clientX, y: e.clientY, cx: view.cx, cy: view.cy, moved: false }; cv.setPointerCapture(e.pointerId); });
cv.addEventListener("pointermove", e => {
const r = cv.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
if (drag) { const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (Math.abs(dx) + Math.abs(dy) > 3) { drag.moved = true; userView = true; view.cx = drag.cx - dx / view.s; view.cy = drag.cy + dy / view.s; } return; }
const c = nearestCity(mx, my);
if (c >= 0) { const nb = [...SC.long, ...SC.short].filter(([f]) => f.a === c || f.b === c).length; const nf = FL.filter(f => f.a === c || f.b === c).length;
showTip(`<b>${cityName(c)}</b><span>${D.cities[c][4]} · ${D.cities[c][3]}</span><span>${nf} routes, ${nb} broken here</span>`, e.clientX, e.clientY); return; }
const l = nearestLine(mx, my);
if (l) { const km = dist(sv.X, 2, l[1].a, l[1].b); showTip(l[0] === "f" ? flightTip(l[1], l[2], km) : limTip(l[1], l[2], km), e.clientX, e.clientY); return; }
tip.hidden = true;
});
cv.addEventListener("pointerup", e => {
const r = cv.getBoundingClientRect();
if (drag && !drag.moved) { const c = nearestCity(e.clientX - r.left, e.clientY - r.top); sel = c === sel ? -1 : c; }
drag = null;
});
cv.addEventListener("pointerleave", () => { tip.hidden = true; });
cv.addEventListener("wheel", e => { e.preventDefault(); userView = true; const r = cv.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top;
const wx = view.cx + (mx - W / 2) / view.s, wy = view.cy - (my - H / 2) / view.s; const k = Math.exp(-e.deltaY * 0.0015); view.s *= k;
view.cx = wx - (mx - W / 2) / view.s; view.cy = wy + (my - H / 2) / view.s; }, { passive: false });
cv.addEventListener("dblclick", () => { userView = false; });
$("#worst").addEventListener("click", e => { const li = e.target.closest("li[data-a]"); if (li) { sel = +li.dataset.a; } });
for (const b of document.querySelectorAll("#starts button")) b.addEventListener("click", () => {
S.start = b.dataset.start; for (const x of document.querySelectorAll("#starts button")) x.setAttribute("aria-pressed", String(x === b)); sel = -1; restart();
});
$("#run").addEventListener("click", () => {
if (sv.done) { continueSolve(); return; }
running = !running; $("#run").textContent = running ? "Pause" : "Solve";
});
$("#reset").addEventListener("click", () => restart());
for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; continueSolve(); });
$("#lo").addEventListener("input", e => { $("#lov").textContent = e.target.value + "%"; });
$("#hi").addEventListener("input", e => { $("#hiv").textContent = e.target.value + "%"; });
$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; continueSolve(); });
$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; continueSolve(); });
for (const b of document.querySelectorAll("#light button")) b.addEventListener("click", () => {
S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); continueSolve();
});
$("#dl").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(D)], { type: "application/json" }); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = "best-flat-map.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
});
/* ---------------- 3D ---------------- */
const c3 = $("#c3"), g3 = c3.getContext("2d");
let W3 = 0, H3 = 0, s3 = null, yaw = 0.4, pitch = -0.5, spin = true, coast3 = null, coast3At = -1, sph = null, flatWrap = null;
function sphereFit(X) { // least-squares sphere through the points; reach = angle from mean direction to farthest point
const A = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], B = [0, 0, 0, 0];
for (let i = 0; i < N; i++) { const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2]; const row = [2 * x, 2 * y, 2 * z, 1], rhs = x * x + y * y + z * z; for (let p = 0; p < 4; p++) { B[p] += row[p] * rhs; for (let q = 0; q < 4; q++) A[p][q] += row[p] * row[q]; } }
const M = A.map((r, i) => r.concat([B[i]]));
for (let i = 0; i < 4; i++) { let p = i; for (let r = i + 1; r < 4; r++) if (Math.abs(M[r][i]) > Math.abs(M[p][i])) p = r; [M[i], M[p]] = [M[p], M[i]]; if (Math.abs(M[i][i]) < 1e-12) return null;
for (let r = 0; r < 4; r++) { if (r === i) continue; const f = M[r][i] / M[i][i]; for (let c = i; c <= 4; c++) M[r][c] -= f * M[i][c]; } }
const s = M.map((r, i) => r[4] / r[i]); const c = s.slice(0, 3); const rad = Math.sqrt(s[3] + c[0] ** 2 + c[1] ** 2 + c[2] ** 2);
const off = [], u = []; let m = [0, 0, 0];
for (let i = 0; i < N; i++) { const v = [X[3 * i] - c[0], X[3 * i + 1] - c[1], X[3 * i + 2] - c[2]]; const d = Math.hypot(...v); off.push(Math.abs(d - rad) / rad); const w = v.map(t => t / d); u.push(w); m = m.map((t, k) => t + w[k]); }
const ml = Math.hypot(...m); m = m.map(t => t / ml); let wrap = 0; for (const w of u) wrap = Math.max(wrap, Math.acos(Math.max(-1, Math.min(1, w[0] * m[0] + w[1] * m[1] + w[2] * m[2]))));
off.sort((p, q) => p - q); return { rad, off: off[N >> 1], wrap: wrap / D2R, c };
}
function lift() {
const X0 = new Float64Array(N * 3); let seed = 7;
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
for (let i = 0; i < N; i++) { const u = Math.max(1e-9, rnd()), v = rnd(); X0[3 * i] = sv.X[2 * i]; X0[3 * i + 1] = sv.X[2 * i + 1]; X0[3 * i + 2] = 50 * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }
s3 = makeSolver(3, X0); s3.flatBroken = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length; coast3At = -1; $("#lift").textContent = "Release again";
}
function rot(x, y, z) { // yaw about vertical, then pitch
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
const x1 = cy * x + sy * z, z1 = -sy * x + cy * z; return [x1, cp * y - sp * z1, sp * y + cp * z1];
}
function draw3() {
if (!W3) return;
g3.setTransform(DPR, 0, 0, DPR, 0, 0); g3.clearRect(0, 0, W3, H3); g3.fillStyle = "#040a16"; g3.fillRect(0, 0, W3, H3);
const X = s3 ? s3.X : null;
if (!X) { g3.fillStyle = COL.faint; g3.font = "500 13px Inter, sans-serif"; g3.textAlign = "center"; g3.fillText("Release the flat map into 3D to start", W3 / 2, H3 / 2); g3.textAlign = "start"; return; }
let c = [0, 0, 0]; for (let i = 0; i < N; i++) for (let q = 0; q < 3; q++) c[q] += X[3 * i + q] / N;
let rmax = 1; for (let i = 0; i < N; i++) rmax = Math.max(rmax, Math.hypot(X[3 * i] - c[0], X[3 * i + 1] - c[1], X[3 * i + 2] - c[2]));
const sc = Math.min(W3, H3) * 0.44 / rmax;
const pj = (x, y, z) => { const r = rot(x - c[0], y - c[1], z - c[2]); return [W3 / 2 + r[0] * sc, H3 / 2 - r[1] * sc, r[2]]; };
if (coast3At !== s3.total) { coast3 = warp(X, 3); coast3At = s3.total; }
if (s3.done && sph) { // the sphere fitted after the solve, drawn for reference only
const q = pj(...sph.c); g3.beginPath(); g3.arc(q[0], q[1], sph.rad * sc, 0, 7); g3.fillStyle = "rgba(62,230,255,.035)"; g3.fill();
g3.setLineDash([3, 5]); g3.strokeStyle = "rgba(62,230,255,.35)"; g3.lineWidth = 1; g3.stroke(); g3.setLineDash([]);
}
g3.lineWidth = 0.9;
for (const r of SHEET.rings) { // land within reach of the cities; far coasts would only be extrapolated
if (!r.fill) continue;
let prev = null, pn = 0;
for (const p of r.idx) {
const q = pj(coast3[3 * p], coast3[3 * p + 1], coast3[3 * p + 2]);
if (prev && pn && SHEET.near[p]) { g3.strokeStyle = (q[2] + prev[2]) / 2 > 0 ? "rgba(120,170,230,.95)" : "rgba(58,90,128,.3)"; g3.beginPath(); g3.moveTo(prev[0], prev[1]); g3.lineTo(q[0], q[1]); g3.stroke(); }
prev = q; pn = SHEET.near[p];
}
}
const sc3 = score(X, 3);
g3.lineWidth = 1.6;
const L3 = (a, b, col, dash) => { const p = pj(X[3 * a], X[3 * a + 1], X[3 * a + 2]), q = pj(X[3 * b], X[3 * b + 1], X[3 * b + 2]); g3.setLineDash(dash || []); g3.strokeStyle = col; g3.beginPath(); g3.moveTo(p[0], p[1]); g3.lineTo(q[0], q[1]); g3.stroke(); };
for (const [f] of sc3.short) L3(f.a, f.b, COL.warn);
for (const k of ["rt", "rr", "sat"]) for (const [l] of sc3[k]) L3(l.a, l.b, COL.bad, [5, 4]);
for (const [f] of sc3.long) L3(f.a, f.b, COL.bad);
g3.setLineDash([]);
for (let i = 0; i < N; i++) { const q = pj(X[3 * i], X[3 * i + 1], X[3 * i + 2]); g3.beginPath(); g3.arc(q[0], q[1], 2.2, 0, 7); g3.fillStyle = q[2] > 0 ? "#fff" : "rgba(255,255,255,.35)"; g3.fill(); }
const nb = sc3.long.length + sc3.short.length + sc3.rt.length + sc3.rr.length + sc3.sat.length;
$("#r3b").textContent = fmt(nb); $("#r3s").textContent = s3.done ? "done" : STAGE(s3.stage);
$("#r3f").textContent = fmt(s3.flatBroken);
if (s3.total % 10 === 0 || s3.done) { sph = sphereFit(X); }
if (sph) { $("#r3w").textContent = Math.round(sph.wrap) + "°"; $("#r3r").textContent = fmt(sph.rad) + " km"; $("#r3o").textContent = (sph.off * 100).toFixed(1) + "%"; }
}
c3.addEventListener("pointerdown", e => { const d = { x: e.clientX, y: e.clientY, yaw, pitch }; c3.setPointerCapture(e.pointerId);
const mv = ev => { yaw = d.yaw + (ev.clientX - d.x) * 0.008; pitch = Math.max(-1.5, Math.min(1.5, d.pitch + (ev.clientY - d.y) * 0.008)); };
const up = () => { c3.removeEventListener("pointermove", mv); c3.removeEventListener("pointerup", up); }; c3.addEventListener("pointermove", mv); c3.addEventListener("pointerup", up); });
$("#lift").addEventListener("click", lift);
$("#spin").addEventListener("click", e => { spin = !spin; e.target.setAttribute("aria-pressed", String(spin)); e.target.textContent = spin ? "Turning" : "Still"; });
/* ---------------- results table ---------------- */
$("#bands tbody").innerHTML = D.bands.map(b => `<tr><th scope="row">${Math.round((1 - b.lo) * 100)}% – ${Math.round((1 + b.hi) * 100)}%</th><td>${b.flat.long}</td><td>${b.flat.short}</td><td>${b.flat.limits}</td><td>${b.flat.below75}</td><td>${b.globe.long}</td><td>${b.globe.short}</td><td>${b.globe.limits}</td></tr>`).join("");
/* ---------------- loop ---------------- */
let frame = 0;
function loop() {
frame++;
if (sv && running && !sv.done) { work(sv, 9, 400); if (sv.done) { running = false; $("#run").textContent = "Solve"; } }
if (sv && (running || frame % 6 === 0)) update(frame % 30 === 0);
if (sv) {
const k = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length;
$("#status").innerHTML = `${sv.done ? "Done" : "Solving, " + STAGE(sv.stage)} · broken <b>${fmt(k)}</b>`;
}
drawMap();
if (s3 && !s3.done) work(s3, 7, 300);
if (spin && s3) yaw += 0.003;
draw3();
requestAnimationFrame(loop);
}
new ResizeObserver(resize).observe(document.body);
resize(); restart(); requestAnimationFrame(loop);
</script>
</body>
</html>
+459
View File
@@ -0,0 +1,459 @@
"""The Sync Test: ISS position and an imitated signal on four maps.
../.venv/bin/python build.py -> data/sync.json, site/index.html
Orbit: data/iss_25544_20261028.tle, fetched 2026-10-29 from
https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE
(epoch 2026-10-28 14:18 UTC), propagated with SGP4 (python sgp4).
For every second of 16 orbits:
station SGP4 position -> Earth-fixed (GMST rotation) -> geodetic lat/lon
map speed speed of the point beneath the station across each flat map
(models.py: North-pole AE, dual-pole AE on 0N 0E, Mercator with its
east and west edges joined)
signal starts level with the station at each northbound equator
crossing and moves along the map's track at the measured speed:
7.07 km/s, the straight-line (level-flight) reading of the SatNOGS
recordings of 3 Oct 2026 (satnogs.py), which over a plane is also
the speed of the point beneath. lag = time since the station was
where the signal is.
On the globe the same recordings fit the published orbit (7.36 km/s at
altitude, x R/r on the ground); check_globe() confirms the signal stays level
there, as it must, since both come from the orbit.
"""
import json, math, os, sys
import numpy as np
from sgp4.api import Satrec, jday
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "src")
sys.path.insert(0, SRC)
from models import R # 6371.0 km sphere shared with the cable survey
from build import land # Natural Earth 110m, rings of [lon, lat]
TLE = os.path.join(HERE, "data", "iss_25544_20261028.tle")
START = (2026, 10, 28, 15, 0, 0) # first northbound equator crossing after this
N_ORBITS = 16
MARGIN_S = 2 * 3600 # track after the last orbit, for a signal running ahead
STEP = 10 # s between samples sent to the page
G0 = 9.80665e-3 # km/s^2
OMEGA = 7.2921150e-5 # rad/s
WGS_A, WGS_F = 6378.137, 1 / 298.257223563
MIN_POP = 1_000_000
DEDUP_KM = 30 # merge districts of one city
SATNOGS = os.path.join(HERE, "data", "satnogs_20261003.json") # satnogs.py
F_UHF = 437.55e6 # Hz, the recorded downlink
C_KMS = 299_792.458
def gmst(jd):
t = (jd - 2451545.0) / 36525.0
s = 67310.54841 + (876600 * 3600 + 8640184.812866) * t + 0.093104 * t**2 - 6.2e-6 * t**3
return np.radians((s % 86400.0) / 240.0)
def propagate(sat, t0jd, t0fr, secs):
jd = np.full(secs.shape, t0jd)
fr = t0fr + secs / 86400.0
e, r, v = sat.sgp4_array(jd, fr)
assert not e.any(), "sgp4 error"
g = gmst(jd + fr)
c, s = np.cos(g), np.sin(g)
x = c * r[:, 0] + s * r[:, 1]
y = -s * r[:, 0] + c * r[:, 1]
z = r[:, 2]
vx = c * v[:, 0] + s * v[:, 1] + OMEGA * y
vy = -s * v[:, 0] + c * v[:, 1] - OMEGA * x
vz = v[:, 2]
return np.stack([x, y, z], 1), np.stack([vx, vy, vz], 1)
def geodetic(p):
x, y, z = p.T
e2 = WGS_F * (2 - WGS_F)
lon = np.degrees(np.arctan2(y, x))
rho = np.hypot(x, y)
lat = np.arctan2(z, rho * (1 - e2))
for _ in range(6):
n = WGS_A / np.sqrt(1 - e2 * np.sin(lat) ** 2)
h = rho / np.cos(lat) - n
lat = np.arctan2(z, rho * (1 - e2 * n / (n + h)))
return np.degrees(lat), lon, h
def unit(lat, lon):
p, l = np.radians(lat), np.radians(lon)
return np.stack([np.cos(p) * np.cos(l), np.cos(p) * np.sin(l), np.sin(p)], -1)
def to_latlon(u):
u = u / np.linalg.norm(u, axis=-1, keepdims=True)
return np.degrees(np.arcsin(np.clip(u[..., 2], -1, 1))), np.degrees(np.arctan2(u[..., 1], u[..., 0]))
# flat maps, vectorised forms of models.ae_xy / dualpole_xy / mercator_xy (km)
def ae_xy(lat, lon):
r = R * np.radians(90 - lat)
t = np.radians(lon)
return r * np.sin(t), -r * np.cos(t)
def dualpole_xy(lat, lon):
p, l = np.radians(lat), np.radians(lon)
c = np.arccos(np.clip(np.cos(p) * np.cos(l), -1, 1))
k = np.where(c < 1e-9, 1.0, c / np.where(c < 1e-9, 1.0, np.sin(c)))
return R * k * np.cos(p) * np.sin(l), R * k * np.sin(p)
def mercator_xy(lat, lon):
return R * np.radians(lon), R * np.log(np.tan(np.pi / 4 + np.radians(np.clip(lat, -85, 85)) / 2))
MAPS = {"ae": ae_xy, "dualpole": dualpole_xy, "mercator": mercator_xy}
WRAP = {"mercator": 2 * math.pi * R}
def step_lengths(f, u, wrap=None):
"""Length on the map of each 1-s step between unit vectors u[i] and u[i+1].
Steps longer than 20 km on the map (dual-pole rim) are subdivided along the
great circle until the pieces are short."""
lat, lon = to_latlon(u)
x, y = f(lat, lon)
dx, dy = np.diff(x), np.diff(y)
if wrap:
dx = (dx + wrap / 2) % wrap - wrap / 2
d = np.hypot(dx, dy)
for i in np.nonzero(d > 20)[0]:
n = int(min(20000, math.ceil(d[i] / 2)))
w = np.linspace(0, 1, n + 1)[:, None]
sub = u[i] * (1 - w) + u[i + 1] * w
la, lo = to_latlon(sub)
sx, sy = f(la, lo)
ddx = np.diff(sx)
if wrap:
ddx = (ddx + wrap / 2) % wrap - wrap / 2
d[i] = np.hypot(ddx, np.diff(sy)).sum()
return d
def main():
sn = json.load(open(SATNOGS))
v_sig = round(sn["v_kms"], 2) # straight-line reading of the recordings = speed in level flight over a plane
l1, l2 = [s.strip() for s in open(TLE).read().splitlines()[1:3]]
sat = Satrec.twoline2rv(l1, l2)
jd0, fr0 = jday(*START)
span = int(N_ORBITS * 93 * 60 + 3 * 3600)
secs = np.arange(span + 1, dtype=float)
pos, vel = propagate(sat, jd0, fr0, secs)
lat, lon, alt = geodetic(pos)
# northbound equator crossings (linear interpolation within the second)
up = np.nonzero((lat[:-1] < 0) & (lat[1:] >= 0))[0]
nodes = up + (-lat[up]) / (lat[up + 1] - lat[up])
assert len(nodes) >= N_ORBITS + 1
t0 = math.floor(nodes[0])
nodes = nodes[: N_ORBITS + 1] - t0
end = int(math.ceil(nodes[-1])) + MARGIN_S
sl = slice(t0, t0 + end + 1)
secs, pos, vel, lat, lon, alt = secs[sl] - t0, pos[sl], vel[sl], lat[sl], lon[sl], alt[sl]
u = unit(lat, lon)
vmeas = np.linalg.norm(vel, axis=1) # globe reading: km/s relative to the ground, at altitude
orbit_of = np.clip(np.searchsorted(nodes, secs, side="right") - 1, 0, N_ORBITS - 1)
inorb = secs < nodes[-1]
spans = [(int(math.ceil(nodes[k])), int(nodes[k + 1])) for k in range(N_ORBITS)]
# globe: point beneath on the 6371 km sphere (geocentric), and the ground speed the measured speed gives it (x R/r)
r = np.linalg.norm(pos, axis=1)
uc = pos / r[:, None]
vglobe = R * np.arccos(np.clip((uc[:-1] * uc[1:]).sum(1), -1, 1))
vglobe = np.concatenate([[vglobe[0]], (vglobe[:-1] + vglobe[1:]) / 2, [vglobe[-1]]])
aglobe = np.zeros_like(vglobe)
aglobe[5:-5] = (vglobe[10:] - vglobe[:-10]) / 10.0 / G0
vt = vel - (vel * uc).sum(1)[:, None] * uc
vg_ref = np.linalg.norm(vt, axis=1) * R / r
out = {"maps": {}}
stats = {}
for name, f in MAPS.items():
d = step_lengths(f, u, WRAP.get(name))
S = np.concatenate([[0.0], np.cumsum(d)]) # map km travelled
v = np.concatenate([[d[0]], (d[:-1] + d[1:]) / 2, [d[-1]]])
a = np.zeros_like(v)
a[5:-5] = (v[10:] - v[:-10]) / 10.0 / G0 # g, change of speed along the track
lag, gap = signal(S, secs, nodes, orbit_of, v_sig)
out["maps"][name] = {"S": S, "v": v, "a": a, "lag": lag, "gap": gap}
# restarted every minute instead of every orbit
sync = np.arange(0.0, nodes[-1], 60.0)
lag60, gap60 = signal(S, secs, sync, np.clip(np.searchsorted(sync, secs, side="right") - 1, 0, len(sync) - 1), v_sig)
ends = [np.interp(n - 1, secs, lag) for n in nodes[1:]]
stats[name] = {
"v_min": float(v[inorb].min()), "v_max": float(v[inorb].max()),
"a_max": float(np.abs(a[inorb]).max()),
"ratio_per_orbit": [float(v[a0:b0].max() / v[a0:b0].min()) for a0, b0 in spans],
"swing_per_orbit": [float(np.abs(np.diff(v[a0:b0])).sum()) for a0, b0 in spans],
"track_per_orbit": [float(S[b0] - S[a0]) for a0, b0 in spans],
"lag_end_mean": float(np.mean(ends)), "lag_end_min": float(np.min(ends)), "lag_end_max": float(np.max(ends)),
"lag_max": float(lag[inorb].max()), "lead_max": float(-lag[inorb].min()),
"gap_max": float(np.abs(gap[inorb]).max()),
"lag60_max": float(np.abs(lag60[inorb]).max()), "gap60_max": float(np.abs(gap60[inorb]).max()),
}
stats[name].update(one_orbit_after(S, secs, nodes, v_sig))
# dual-pole rim: closest approach to 0N 180E
rim = np.degrees(np.arccos(np.clip(-u[inorb][:, 0], -1, 1)))
stats["dualpole"]["rim_min_deg"] = float(rim.min())
# cities: station or signal above the horizon (mean altitude), XOR = out of sync
h_mean = float(alt[inorb].mean())
lam = math.acos(R / (R + h_mean))
cities = load_cities()
cu = unit(np.array([c[1] for c in cities]), np.array([c[2] for c in cities]))
cl = math.cos(lam)
idx = np.arange(0, int(nodes[-1]), STEP)
sees = (u[idx] @ cu.T) >= cl
worst = np.full(len(idx), 10**9)
for name in MAPS:
tau_i = idx - out["maps"][name]["lag"][idx]
uu = np.stack([np.interp(tau_i, secs, u[:, k]) for k in range(3)], 1)
bad = sees ^ ((uu @ cu.T) >= cl)
worst = np.minimum(worst, bad.sum(1))
stats[name]["cities_now_max"] = int(bad.sum(1).max())
# opening frame: within the first two orbits, at least 10 min before a restart and with cities out of
# sync on every map, the moment when the smallest lag over the three maps is largest
lagmin = np.min([out["maps"][n]["lag"][idx] for n in MAPS], axis=0)
nxt = nodes[np.minimum(orbit_of[idx] + 1, N_ORBITS)]
ok = (idx < nodes[2]) & (nxt - idx >= 600) & (worst >= 8)
t_init = int(idx[np.argmax(np.where(ok, lagmin, -1))])
stats["globe"] = {"v_min": float(vglobe[inorb].min()), "v_max": float(vglobe[inorb].max()),
"a_max": float(np.abs(aglobe[inorb]).max()),
"ratio_per_orbit": [float(vglobe[a0:b0].max() / vglobe[a0:b0].min()) for a0, b0 in spans],
"swing_per_orbit": [float(np.abs(np.diff(vglobe[a0:b0])).sum()) for a0, b0 in spans],
"track_per_orbit": [float(vglobe[a0:b0].sum()) for a0, b0 in spans],
"signal_drift_km_max": check_globe(secs, uc, vg_ref, nodes, orbit_of),
"ref_mean": float(vg_ref[inorb].mean())}
stats["measured"] = {"v_sig": v_sig, "v_min": float(vmeas[inorb].min()), "v_max": float(vmeas[inorb].max()),
"alt_mean": h_mean, "horizon_deg": math.degrees(lam), "horizon_km": R * lam,
"period_min": float(np.diff(nodes).mean() / 60), "signal_per_orbit_km": v_sig * float(np.diff(nodes).mean())}
stats["recorded"] = recorded_map_speeds(sn, lat, out, inorb)
stats["one_listener"] = one_listener(u, uc, lat, lon, h_mean)
stats["satnogs"] = {k: sn[k] for k in ("v_kms", "burst")} | {
"passes": {k: {x: q[x] for x in ("utc", "stations", "t0_rms_s", "v_kms", "offset_sd_median_hz")} for k, q in sn["passes"].items()}}
# page data: 10-s samples
k = np.arange(0, end + 1, STEP)
epoch = jd0 + fr0 + t0 / 86400.0
page = {
"tle": [l1, l2], "epoch_unix": round((epoch - 2440587.5) * 86400.0, 3), "step": STEP,
"nodes": [round(n, 2) for n in nodes.tolist()], "horizon_deg": round(math.degrees(lam), 3),
"alt_km": round(h_mean, 1), "t_init": t_init, "v_sig": v_sig, "vg_ref": round(stats["globe"]["ref_mean"], 4),
"lat": np.round(lat[k], 3).tolist(), "lon": np.round(lon[k], 3).tolist(),
"vmeas": np.round(vmeas[k] * 1000).astype(int).tolist(), # m/s
"vglobe": np.round(vglobe[k] * 1000).astype(int).tolist(), # m/s
"aglobe": np.round(aglobe[k] * 1000).astype(int).tolist(), # milli-g
"maps": {n: {"v": np.round(m["v"][k] * 1000).astype(int).tolist(), # m/s
"a": np.round(m["a"][k] * 1000).astype(int).tolist(), # milli-g
"S": np.round(m["S"][k], 1).tolist()} # map km travelled; the page derives the signal
for n, m in out["maps"].items()},
"cities": [list(c[:4]) for c in cities],
"stats": stats,
}
with open(os.path.join(HERE, "data", "sync.json"), "w") as fh:
json.dump(page, fh, separators=(",", ":"))
vals = copy_values(page, stats)
if not os.path.exists(os.path.join(HERE, "template.html")):
return
page["land"] = land()
tpl = open(os.path.join(HERE, "template.html"), encoding="utf-8").read()
d3 = open(os.path.join(SRC, "raw", "lib", "d3.min.js"), encoding="utf-8").read()
assert tpl.count("/*DATA*/null") == 1 and tpl.count("/*D3*/") == 1 and "</script" not in d3
unused = [k for k in vals if "{{" + k + "}}" not in tpl]
if unused:
print("not quoted on the page:", ", ".join(unused))
for key, val in vals.items():
tpl = tpl.replace("{{" + key + "}}", val)
assert "{{" not in tpl, tpl[tpl.index("{{"):][:40]
html = tpl.replace("/*DATA*/null", json.dumps(page, separators=(",", ":"))).replace("/*D3*/", d3)
with open(os.path.join(HERE, "site", "index.html"), "w", encoding="utf-8") as fh:
fh.write(html)
def signal(S, secs, sync, k, v_sig):
"""Source level with the station at each sync time, then moving along the map track at v_sig.
Returns lag (s since the station was where the signal is) and gap (map km, station ahead > 0)."""
Ssync = np.interp(sync[k], secs, S)
sig = Ssync + v_sig * (secs - sync[k])
return secs - np.interp(sig, S, secs), S - sig
def one_orbit_after(S, secs, nodes, v_sig):
"""Lag and gap one orbit after the signal is lined up, for every start time 1 min apart."""
P = float(np.diff(nodes).mean())
starts = np.arange(0.0, nodes[-1] - P, 60.0)
target = np.interp(starts, secs, S) + v_sig * P
lag = starts + P - np.interp(target, S, secs)
gap = np.interp(starts + P, secs, S) - target
return {"lag1_min": float(lag.min()), "lag1_max": float(lag.max()), "gap1_min": float(gap.min()), "gap1_max": float(gap.max())}
def load_cities():
"""GeoNames cities of MIN_POP or more; places within DEDUP_KM of a larger one are merged into it."""
rows = []
for row in open(os.path.join(SRC, "raw", "geonames", "cities15000.txt"), encoding="utf-8"):
c = row.rstrip("\n").split("\t")
if int(c[14] or 0) >= MIN_POP:
rows.append((c[2], round(float(c[4]), 3), round(float(c[5]), 3), c[8], int(c[14])))
rows.sort(key=lambda c: -c[4])
kept, ku = [], []
for c in rows:
cv = unit(np.array(c[1]), np.array(c[2]))
if ku and (np.array(ku) @ cv).max() >= math.cos(DEDUP_KM / R):
continue
kept.append(c); ku.append(cv)
return kept
def recorded_map_speeds(sn, lat, out, inorb):
"""Map speed at each SatNOGS receiver's closest approach (3 Oct 2026). On the North-pole AE and Mercator
maps the speed depends only on latitude and direction (northbound / southbound), so it is read from this
orbit's track at the same latitude and direction."""
north = np.gradient(lat) > 0
res = {}
for key, p in sn["passes"].items():
ca = sorted(p["ca"], key=lambda x: x[2])
nb = ca[-1][0] > ca[0][0] if abs(ca[-1][0] - ca[0][0]) > 1 else None
row = {"recorded_kms": p["v_kms"], "northbound": nb}
for name in ("ae", "mercator"):
v = out["maps"][name]["v"]
sp = []
for la, lo, t in ca:
if nb is None: # pass near its northern or southern limit: take both branches
m = inorb
else:
m = inorb & (north == nb)
i = np.nonzero(m)[0]
j = i[np.argmin(np.abs(lat[i] - la))]
sp.append(float(v[j]))
row[name] = {"mean": float(np.mean(sp)), "max": float(np.max(sp))}
res[key] = row
return res
def one_listener(u, uc, lat, lon, h):
"""The station over the North-pole AE plane, adjusting its own frequency so that one receiver
(VK4JBE, Brisbane) hears the shift the globe predicts, 2 min before its highest pass in this window.
Returns the error heard by neighbours at F_UHF."""
rx = (-27.492, 153.069)
ball = uc * (R + h)
dx, dy = ae_xy(lat, lon)
disc = np.stack([dx, dy, np.full_like(dx, h)], 1)
def rxpos(la, lo):
e = unit(np.array(la), np.array(lo))
q = ae_xy(np.array(la), np.array(lo))
return e * R, e, np.array([float(q[0]), float(q[1]), 0.0])
def shifts(la, lo):
P, e, Q = rxpos(la, lo)
fb = -F_UHF / C_KMS * np.gradient(np.linalg.norm(ball - P, axis=1))
fd = -F_UHF / C_KMS * np.gradient(np.linalg.norm(disc - Q, axis=1))
el = np.degrees(np.arcsin(np.clip(((ball - P) @ e) / np.linalg.norm(ball - P, axis=1), -1, 1)))
return fb, fd, el
fb, fd, el = shifts(*rx)
tca = int(np.argmax(el))
t = tca - 120
cor = fb[t] - fd[t]
res = {"rx": "VK4JBE, Brisbane", "max_el": float(el[tca]), "el": float(el[t]), "correction_hz": float(cor)}
for dk, brg, nm in ((100, 90, "east100"), (1000, 0, "north1000")):
p, l, dd, b = math.radians(rx[0]), math.radians(rx[1]), dk / R, math.radians(brg)
p2 = math.asin(math.sin(p) * math.cos(dd) + math.cos(p) * math.sin(dd) * math.cos(b))
l2 = l + math.atan2(math.sin(b) * math.sin(dd) * math.cos(p), math.cos(dd) - math.sin(p) * math.sin(p2))
fb2, fd2, el2 = shifts(math.degrees(p2), math.degrees(l2))
res[nm] = {"error_hz": float(fd2[t] + cor - fb2[t]), "el": float(el2[t])}
return res
def mmss(s):
s = int(round(s))
return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
def fmt_v(x):
return f"{x:,.0f}" if x >= 100 else f"{x:.2f}"
def rng(xs, f):
lo, hi = f(min(xs)), f(max(xs))
return lo if lo == hi else f"{lo}–{hi}"
def copy_values(page, st):
"""Numbers quoted in the page text, from this run."""
import datetime as dt
utc = lambda t: dt.datetime.fromtimestamp(page["epoch_unix"] + t, dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
m, ae, dp, me, gl, rec, one, sn = (st[k] for k in ("measured", "ae", "dualpole", "mercator", "globe", "recorded", "one_listener", "satnogs"))
l1 = page["tle"][0]
yy, doy = int(l1[18:20]), float(l1[20:32])
ep = dt.datetime(2000 + yy, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta(days=doy - 1)
b = sn["burst"]
rms = [q["t0_rms_s"] for q in sn["passes"].values()]
el = lambda km: f"{math.degrees(math.atan2(m['alt_mean'], km)):.1f}"
v = {
"V_SIG": f"{m['v_sig']:.2f}", "V_ORBIT_RANGE": f"{m['v_min']:.2f}–{m['v_max']:.2f}",
"ALT": f"{m['alt_mean']:.0f}", "PERIOD": f"{m['period_min']:.1f}",
"HORIZON_DEG": f"{m['horizon_deg']:.1f}", "HORIZON_KM": f"{m['horizon_km']:,.0f}",
"EL_HORIZON": el(m["horizon_km"]), "EL_10000": el(10000),
"GLOBE_V_RANGE": f"{gl['v_min']:.2f}–{gl['v_max']:.2f}", "GLOBE_DRIFT": f"{gl['signal_drift_km_max']:.2f}",
"N_CITIES": f"{len(page['cities'])}", "N_ORBITS": f"{len(page['nodes']) - 1}",
"START_UTC": utc(page["nodes"][0]), "EPOCH_UTC": ep.strftime("%Y-%m-%d %H:%M UTC"),
"SIG_TRACK": f"{round(m['signal_per_orbit_km'], -2):,.0f}",
"AE_LAG": f"{ae['lag_end_mean'] / 60:.0f} min", "ME_LAG": f"{me['lag_end_mean'] / 60:.0f} min",
"DP_LAG": f"{dp['lag_end_min'] / 60:.0f}–{dp['lag_end_max'] / 60:.0f} min",
"AE_LAG60": mmss(ae["lag60_max"]), "AE_GAP60": f"{round(ae['gap60_max'], -2):,.0f}",
"SN_RMS": rng(rms, lambda x: f"{x:.1f}"), "SN_N": f"{sum(q['stations'] for q in sn['passes'].values())}",
"SN_OFFSET": f"{np.median([q['offset_sd_median_hz'] for q in sn['passes'].values()]):.0f}",
"SN_V": f"{sn['v_kms']:.2f}",
"BURST_UTC": b["utc"].replace(" UTC", "")[11:] + " UTC on 3 October 2026", "BURST_N": f"{b['receivers']}",
"BURST_UP": f"{b['up']}", "BURST_DOWN": f"{b['down']}",
"BURST_MIN": f"−{abs(b['min_hz']) / 1000:.1f}", "BURST_MAX": f"+{b['max_hz'] / 1000:.1f}",
"REC_AE_SOUTH": rng([rec[k]["ae"]["mean"] for k in ("2026-10-03_SouthAmerica", "2026-10-03_Australia")], lambda x: f"{x:.0f}"),
"REC_AE_EU": f"{rec['2026-10-03_Europe']['ae']['mean']:.1f}",
"REC_V_SOUTH": rng([rec[k]["recorded_kms"] for k in ("2026-10-03_SouthAmerica", "2026-10-03_Australia")], lambda x: f"{x:.2f}"),
"REC_V_EU": f"{rec['2026-10-03_Europe']['recorded_kms']:.2f}",
"ONE_COR": f"{abs(one['correction_hz']) / 1000:.1f}", "ONE_E100": f"{abs(one['east100']['error_hz']):,.0f}",
"ONE_N1000": f"{abs(one['north1000']['error_hz']) / 1000:.1f}",
"DP_RIM": f"{dp['rim_min_deg']:.1f}",
"DP_VMAX": f"{dp['v_max']:,.0f}",
"AE_XSIG": f"{ae['v_max'] / m['v_sig']:.1f}",
}
for k, s in (("G", gl), ("AE", ae), ("DP", dp), ("ME", me)):
v[k + "_VR"] = f"{fmt_v(s['v_min'])}–{fmt_v(s['v_max'])}"
v[k + "_AMAX"] = f"{s['a_max']:,.0f}" if s["a_max"] >= 100 else f"{s['a_max']:.2f}"
v[k + "_RATIO"] = "×" + rng(s["ratio_per_orbit"], lambda x: f"{x:.2f}" if x < 10 else f"{x:.0f}")
v[k + "_SWING"] = rng(s["swing_per_orbit"], lambda x: f"{x:,.0f}" if x >= 10 else f"{x:.2f}")
v[k + "_TRACK"] = rng(s["track_per_orbit"], lambda x: f"{round(x, -2):,.0f}")
if k != "G":
v[k + "_LAGEND"] = (mmss(s["lag_end_mean"]) if s["lag_end_max"] - s["lag_end_min"] < 2
else f"{mmss(s['lag_end_min'])} – {mmss(s['lag_end_max'])}")
v[k + "_GAP"] = f"{s['gap_max']:,.0f}"
v[k + "_LAG1"] = f"{s['lag1_min'] / 60:.0f}–{s['lag1_max'] / 60:.0f} min"
v[k + "_GAP1"] = f"{round(s['gap1_min'], -2):,.0f}–{round(s['gap1_max'], -2):,.0f}"
v[k + "_LAG60"] = mmss(s["lag60_max"])
assert abs(gl["ratio_per_orbit"][0] - 1) < 0.01 and gl["signal_drift_km_max"] < 1
return v
def check_globe(secs, uc, vg, nodes, orbit_of):
"""Globe: a signal moving at the measured speed scaled to the ground (R/r), started level at each
crossing, against the station's point beneath on the sphere. Largest separation in km: zero up to
integration error, since both come from the same orbit."""
ang = np.arccos(np.clip((uc[:-1] * uc[1:]).sum(1), -1, 1))
S = np.concatenate([[0.0], np.cumsum(R * ang)])
M = np.concatenate([[0.0], np.cumsum((vg[:-1] + vg[1:]) / 2)])
m = secs < nodes[-1]
sig = np.interp(nodes[orbit_of], secs, S) + M - np.interp(nodes[orbit_of], secs, M)
return float(np.abs(S - sig)[m].max())
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
ISS (ZARYA)
1 25544U 98067A 26301.59601386 .00005724 00000+0 11161-3 0 9990
2 25544 51.6336 359.9178 0006994 320.4528 39.5949 15.49090321592436
+336
View File
@@ -0,0 +1,336 @@
{
"source": "The Shape of Distance, Listening to the ISS (https://distance.aethercosmology.com/#doppler)",
"passes": {
"2026-10-03_Europe": {
"utc": "2026-10-03 12:19 UTC",
"stations": 45,
"t0_rms_s": 1.5110702608857516,
"v_kms": 7.071278974220315,
"ca": [
[
49.68097225065866,
-4.286398095275085,
-147.41300439834595
],
[
50.17352884342994,
-1.7422410616196602,
-120.22392249107361
],
[
50.28722677319256,
-1.0969439006134212,
-113.29120254516602
],
[
50.34359200582169,
-0.7672780083574665,
-107.26256608963013
],
[
50.39813807454715,
-0.4416178141532689,
-106.28527522087097
],
[
50.474938763776336,
0.028721735570883075,
-100.27764987945557
],
[
50.458242263668495,
-0.08031116446249402,
-102.48805689811707
],
[
50.48642262597799,
0.10030038460694612,
-99.0170385837555
],
[
50.45246196129986,
-0.11597005348848836,
-102.86739993095398
],
[
50.70001449932109,
1.4983330525942538,
-85.73596286773682
],
[
50.88022658484752,
2.7955569795944033,
-71.13996124267578
],
[
50.890315319090526,
2.8720176595924722,
-69.3419349193573
],
[
50.83936569470999,
2.4903428480145116,
-75.32912468910217
],
[
51.0289501837351,
3.9719734944273135,
-58.40026807785034
],
[
51.0880129475564,
4.472283630155815,
-55.21893787384033
],
[
51.11690276007427,
4.724988195271039,
-50.607028007507324
],
[
51.154417075514885,
5.061695917564571,
-47.63208723068237
],
[
51.16815681994341,
5.18758780082744,
-46.83433556556702
],
[
51.20593103429147,
5.5413235647036005,
-43.19211935997009
],
[
51.26313560465976,
6.094044822458296,
-30.066311597824097
],
[
51.326402995289214,
6.756550043083508,
-30.72496247291565
],
[
51.36619618748058,
7.193198756269772,
-26.261382579803467
],
[
51.50222554524715,
8.872909168549834,
-9.16100025177002
],
[
51.558044743989555,
9.680108509926336,
-2.478559732437134
],
[
51.566589432203116,
9.812150373988406,
0.3579845428466797
],
[
51.60349260520093,
10.406897638198846,
3.8177101612091064
],
[
51.62705814797918,
10.824044199618344,
11.029848098754883
],
[
51.736418086840835,
13.405629750411013,
34.06613898277283
],
[
51.39860814725719,
7.564712838371189,
-22.969857692718506
],
[
51.77979957060567,
15.475773174887708,
58.828940629959106
],
[
51.77453868481207,
15.078145390694818,
53.34382939338684
],
[
51.78680194014927,
16.804750679477568,
71.64358496665955
],
[
51.786760333614666,
16.578869860803913,
69.38079118728638
],
[
51.78383526684172,
17.51943197961017,
77.30333089828491
],
[
51.77667138982217,
18.19714086438838,
84.59421920776367
],
[
51.77333207609721,
18.423658974218366,
86.36457991600037
],
[
51.74103777964802,
19.862998750112546,
102.30152630805969
],
[
51.75291718453868,
19.427711593038516,
96.87794494628906
],
[
51.74196619765101,
19.830924017483685,
100.97955536842346
],
[
51.69437205398979,
21.187367129137698,
114.60929918289185
],
[
51.7739058089836,
18.386923130428553,
85.99635648727417
],
[
51.55894671029261,
23.729682647005685,
140.25733137130737
],
[
51.34329033761241,
26.48423575805742,
167.76775479316711
],
[
51.3393359762161,
26.527367191357794,
168.70865154266357
],
[
51.786805507363106,
16.623095415982302,
67.82382726669312
]
],
"offset_sd_median_hz": 55.68906970882991
},
"2026-10-03_SouthAmerica": {
"utc": "2026-10-03 08:38 UTC",
"stations": 6,
"t0_rms_s": 2.1213203435596424,
"v_kms": 7.100233927518952,
"ca": [
[
-24.42636520633059,
-39.032266541179744,
267.26572489738464
],
[
-38.03381783704823,
-54.789606627691114,
-40.502705097198486
],
[
-39.330652166899945,
-56.809004937438154,
-75.51542568206787
],
[
-38.02770265998915,
-54.78040726613133,
-42.34938168525696
],
[
-38.02012345473304,
-54.76900975332197,
-43.159380197525024
],
[
-38.14095199215084,
-54.9512402310319,
-41.692227602005005
]
],
"offset_sd_median_hz": 55.52755202642167
},
"2026-10-03_Australia": {
"utc": "2026-10-03 11:20 UTC",
"stations": 6,
"t0_rms_s": 0.6454972243679028,
"v_kms": 7.098692302512828,
"ca": [
[
-30.558236025432794,
149.49188402916255,
0.8984365463256836
],
[
-30.570206668846524,
149.5051140521379,
1.664686918258667
],
[
-33.20733229830597,
152.55894381076263,
59.26902198791504
],
[
-30.60050410953769,
149.53862214953645,
1.3387207984924316
],
[
-32.812113682161325,
152.08238759524934,
51.706862688064575
],
[
-32.92338072550679,
152.2158315683196,
53.75318193435669
]
],
"offset_sd_median_hz": 60.15296495284982
}
},
"v_kms": 7.073275218950461,
"burst": {
"utc": "2026-10-03 12:18:55 UTC",
"receivers": 44,
"up": 22,
"down": 22,
"min_hz": -9057.47484203577,
"max_hz": 9606.405798036318,
"min_name": "M0EYT",
"max_name": "EU1AEM",
"lat_range": [
46.09548,
56.857
],
"lon_range": [
-6.235,
27.17
]
}
}
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
"""SatNOGS facts quoted on the page, from the "Listening to the ISS" tab of
The Shape of Distance (src/handoff/The_Shape_of_Distance_PRESENTATION.html,
page 3; recordings of 3 October 2026 at 437.55 / 437.80 MHz).
python3 satnogs.py -> data/satnogs_20261003.json
"""
import base64, datetime as dt, json, os, re
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
PRES = os.path.join(HERE, "..", "src", "handoff", "The_Shape_of_Distance_PRESENTATION.html")
BURST_T = -10.0 # s from the Europe pass epoch: the instant compared across receivers
def page3():
s = open(PRES, encoding="utf-8").read()
blobs = re.findall(r"[A-Za-z0-9+/]{20000,}={0,2}", s)
html = base64.b64decode(blobs[3]).decode("utf-8")
assert "<title>Listening to the ISS</title>" in html
js = re.findall(r"<script[^>]*>(.*?)</script>", html, flags=re.S)[0]
return json.loads(js[js.index("{"): js.rindex("}") + 1])
def main():
D = page3()
out = {"source": "The Shape of Distance, Listening to the ISS (https://distance.aethercosmology.com/#doppler)", "passes": {}}
for key, p in D["passes"].items():
st = p["stations"]
dt0 = np.array([s["t0"] - s["pub"]["t_ca"] for s in st])
V = np.array([s["V"] for s in st]); E = np.array([s["V_err"] for s in st]); w = 1 / E**2
down = [s["t0"] for s in st]
out["passes"][key] = {
"utc": dt.datetime.fromtimestamp(p["t_epoch"], dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"stations": len(st),
"t0_rms_s": float(np.sqrt((dt0**2).mean())),
"v_kms": float((V * w).sum() / w.sum() / 1000),
# point beneath the station at each receiver's closest approach (published orbit)
"ca": [[s["pub"]["lat"], s["pub"]["lon"], s["t0"]] for s in st],
"offset_sd_median_hz": float(np.median([s["offset_sd"] for s in st])),
}
allV = np.array([s["V"] for p in D["passes"].values() for s in p["stations"]])
allE = np.array([s["V_err"] for p in D["passes"].values() for s in p["stations"]])
out["v_kms"] = float((allV / allE**2).sum() / (1 / allE**2).sum() / 1000)
# one instant of the Europe pass: carrier-corrected shift at every receiver with data within 3 s
p = D["passes"]["2026-10-03_Europe"]
shifts = []
for s in p["stations"]:
pts = np.array(s["pts"]); t = pts[:, 0]; i = np.searchsorted(t, BURST_T)
if 0 < i < len(t) and t[i] - t[i - 1] < 6:
shifts.append((s["name"], float(np.interp(BURST_T, t, pts[:, 1]) - (s["f"] - s["f_nom"])), s["lat"], s["lon"]))
v = np.array([x[1] for x in shifts])
lo, hi = min(shifts, key=lambda x: x[1]), max(shifts, key=lambda x: x[1])
out["burst"] = {
"utc": dt.datetime.fromtimestamp(p["t_epoch"] + BURST_T, dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"receivers": len(shifts), "up": int((v > 0).sum()), "down": int((v < 0).sum()),
"min_hz": float(v.min()), "max_hz": float(v.max()), "min_name": lo[0], "max_name": hi[0],
"lat_range": [min(x[2] for x in shifts), max(x[2] for x in shifts)],
"lon_range": [min(x[3] for x in shifts), max(x[3] for x in shifts)],
}
with open(os.path.join(HERE, "data", "satnogs_20261003.json"), "w") as fh:
json.dump(out, fh, indent=1)
print(json.dumps({k: v for k, v in out.items() if k != "passes"}, indent=1))
for k, q in out["passes"].items():
print(k, q["utc"], q["stations"], "t0 rms %.2f s" % q["t0_rms_s"], "v %.3f" % q["v_kms"], "offset sd %.0f Hz" % q["offset_sd_median_hz"])
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+639
View File
@@ -0,0 +1,639 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Sync Test</title>
<meta name="description" content="The ISS at its published-orbit position on the globe, North-pole AE, dual-pole AE and Mercator maps, with a spoofed radio signal moving at the measured speed of {{V_SIG}} km/s.">
<meta property="og:type" content="website">
<meta property="og:title" content="The Sync Test">
<meta property="og:description" content="ISS position and a spoofed radio signal on four maps. On the globe they stay level; one orbit after the signal is lined up, it is {{AE_LAG1}} behind on the North-pole AE map.">
<meta property="og:url" content="https://iss.aethercosmology.com/">
<meta property="og:image" content="https://iss.aethercosmology.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23081120' stroke='%233ee6ff' stroke-width='2'/%3E%3Ccircle cx='25' cy='9' r='3.5' fill='%23fff'/%3E%3Ccircle cx='22' cy='25' r='3.5' fill='none' stroke='%23ff4fd8' stroke-width='2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
/* Layout: one row of four synchronised map panels on wide screens (2 / 1 columns below), readouts under each map; text in a ~70ch column above and below. */
:root{
--bg:#02050c; --panel:#07101e; --panel2:#0b182b; --line:rgba(120,180,255,.14); --line2:rgba(120,180,255,.28);
--ink:#e8f0fb; --dim:#9badc6; --faint:#65789a;
--station:#ffffff; --signal:#ff4fd8; --track:#3ee6ff; --ok:#3dffa0; --warn:#ffc94a; --bad:#ff4d5e;
--disp:"Space Grotesk","Inter",system-ui,sans-serif;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
color-scheme:dark;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);-webkit-font-smoothing:antialiased;
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:20;background:var(--bg)}
a{color:var(--track);text-decoration-color:rgba(62,230,255,.35);text-underline-offset:2px}
a:hover{text-decoration-color:var(--track)}
:focus-visible{outline:2px solid var(--track);outline-offset:2px}
.wrap{max-width:1720px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.top{padding-block:28px 6px}
h1{font:700 clamp(28px,3.2vw,40px)/1.1 var(--disp);margin:0;letter-spacing:-.01em;text-wrap:balance}
.sub{margin:6px 0 0;color:var(--dim);font-size:15px}
.intro{display:grid;gap:10px 40px;padding-block:14px 18px}
@media (min-width:1100px){.intro{grid-template-columns:repeat(2,minmax(0,64ch))}}
.intro p{margin:0;max-width:74ch;color:var(--ink)}
.intro b{font-family:var(--disp);font-weight:600;color:var(--dim);letter-spacing:.01em}
.k-st{color:var(--station);font-weight:600}.k-sig{color:var(--signal);font-weight:600}.k-bad{color:var(--bad);font-weight:600}
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:rgba(2,5,12,.9);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-block:1px solid var(--line)}
.barin{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding-block:9px}
.sigrow{padding-block:0 9px;gap:8px 22px}
.rowlab{font:500 10.5px var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
@media (max-width:620px){.bar{position:static}}
button{font:inherit;color:inherit}
#play{width:38px;height:34px;border-radius:8px;border:1px solid var(--line2);background:var(--panel2);cursor:pointer;display:grid;place-items:center}
#play svg{width:14px;height:14px;fill:var(--ink)}
.rates{display:flex;border:1px solid var(--line2);border-radius:8px;overflow:hidden}
.rates button{border:0;background:transparent;padding:6px 10px;font:500 12.5px var(--mono);color:var(--dim);cursor:pointer}
.rates button+button{border-left:1px solid var(--line)}
.rates button[aria-pressed="true"]{background:var(--panel2);color:var(--ink)}
#time{flex:1 1 220px;min-width:140px;accent-color:var(--track)}
.sync{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;font-size:12.5px;color:var(--dim)}
.sync button{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink)}
#sync{border-color:rgba(255,79,216,.6)}
#syncinfo{font-variant-numeric:tabular-nums}
.corr{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;font-size:12.5px;color:var(--dim)}
#corr{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--dim)}
#corr[aria-pressed="true"]{color:var(--ink);border-color:rgba(255,79,216,.6)}
#latwrap{display:flex;align-items:center;gap:6px}
#latwrap[hidden]{display:none}
#lat{width:110px;accent-color:var(--signal)}
#latv{font:500 12.5px var(--mono);min-width:3.6em;color:var(--ink)}
#sync:disabled{opacity:.45;cursor:default}
.clock{display:flex;gap:12px;font:500 13px var(--mono);color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
.clock b{color:var(--ink);font-weight:500}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;padding-block:12px 0;margin-block:0;list-style:none;color:var(--dim);font-size:13px}
.legend li{display:flex;align-items:center;gap:7px}
.legend svg{width:22px;height:14px;flex:none}
.grid{display:grid;gap:12px;grid-template-columns:repeat(4,minmax(0,1fr));padding-block:12px 8px}
@media (max-width:1199px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:620px){.grid{grid-template-columns:minmax(0,1fr)}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 10px 8px;display:flex;flex-direction:column;gap:8px;min-width:0}
.ph{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:0}
.ph h2{font:600 16px/1.2 var(--disp);margin:0}
.ph p{margin:0;color:var(--faint);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.map{position:relative;aspect-ratio:1;width:100%;max-width:100%}
.map canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:pan-y}
.ro{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px;margin:0}
.ro div{min-width:0}
.ro .w{grid-column:1/-1}
.ro dt{font:500 10.5px/1.3 var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.ro dd{margin:1px 0 0;font:500 13px/1.35 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pill{display:inline-block;margin-left:6px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;border:1px solid currentColor}
.pill.ok{color:var(--ok)}.pill.warn{color:var(--warn)}.pill.bad{color:var(--bad)}
.sp{display:flex;flex-direction:column;gap:2px}
.sp span{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.sp canvas{width:100%;height:54px;display:block;cursor:pointer}
.after{padding-block:22px 10px;display:grid;gap:10px}
.after h2{font:600 19px/1.25 var(--disp);margin:14px 0 2px}
.after h3{font:600 15.5px/1.3 var(--disp);margin:6px 0 0;color:var(--ink)}
.after p b{color:var(--ink);font-weight:600}
.after p{margin:0;max-width:74ch;color:var(--dim)}
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px;max-width:980px}
table{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
th,td{padding:8px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
tr:last-child td,tr:last-child th{border-bottom:0}
thead th{font:600 12.5px var(--disp);color:var(--dim);background:var(--panel)}
tbody th{text-align:left;font-weight:500;color:var(--dim)}
td{font-family:var(--mono);font-variant-numeric:tabular-nums}
.method{display:grid;grid-template-columns:max-content minmax(0,74ch);gap:8px 20px;margin:0;color:var(--dim)}
.method dt{font:600 13.5px var(--disp);color:var(--ink);padding-top:1px}
.method dd{margin:0}
@media (max-width:620px){.method{grid-template-columns:minmax(0,1fr);gap:2px}.method dd{margin-bottom:10px}}
#dl{justify-self:start;border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:7px 14px;cursor:pointer;font-size:13.5px}
footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
#tip{position:fixed;z-index:30;pointer-events:none;background:rgba(6,12,24,.96);border:1px solid var(--line2);border-radius:8px;padding:6px 9px;font-size:12.5px;line-height:1.4;max-width:240px}
#tip b{font-weight:600}
#tip span{display:block;color:var(--dim)}
</style>
</head>
<body>
<header class="wrap top">
<h1>The Sync Test</h1>
<p class="sub">ISS position and a spoofed radio signal on four maps</p>
</header>
<section class="wrap intro">
<p><b>Hypothesis.</b> The radio signals attributed to the International Space Station come from ground transmitters along its track, imitating a source that moves at the measured speed, {{V_SIG}} km/s.</p>
<p><b>Test.</b> The <span class="k-st">station</span> is placed where the published orbit puts it, which is where it is seen to pass at the predicted times (<a href="https://spotthestation.nasa.gov/">Spot the Station</a>); its speed across each map follows from that map's distances. The <span class="k-sig">spoofed signal</span> is the source those transmitters imitate, moving along the same track at {{V_SIG}} km/s, lined up with the station at each northbound equator crossing, or at any moment with <i>Line up signal now</i>. A city turns <span class="k-bad">red</span> when the station is above its horizon and the signal is not, or the reverse.</p>
</section>
<div class="bar">
<div class="wrap barin">
<button id="play" type="button" aria-label="Pause"><svg viewBox="0 0 14 14" aria-hidden="true"><path id="playicon" d="M2 1h3.5v12H2zM8.5 1H12v12H8.5z"/></svg></button>
<div class="rates" role="group" aria-label="Playback speed">
<button type="button" data-rate="1" aria-pressed="false">×1</button>
<button type="button" data-rate="10" aria-pressed="false">×10</button>
<button type="button" data-rate="60" aria-pressed="true">×60</button>
<button type="button" data-rate="300" aria-pressed="false">×300</button>
<button type="button" data-rate="1000" aria-pressed="false">×1000</button>
</div>
<input id="time" type="range" min="0" max="1" step="10" value="0" aria-label="Time">
<div class="clock"><b id="clock">—</b><span id="orbit">—</span></div>
</div>
<div class="wrap barin sigrow">
<span class="rowlab">Signal</span>
<div class="sync">
<button id="sync" type="button">Line up signal now</button>
<button id="syncauto" type="button" hidden>Each orbit</button>
<span id="syncinfo">Lined up: each northbound equator crossing</span>
</div>
<div class="corr">
<button id="corr" type="button" aria-pressed="false">Real-time correction</button>
<label id="latwrap" hidden>Latency <input id="lat" type="range" min="0" max="9" step="1" value="4" aria-label="Correction latency"> <span id="latv">10 s</span></label>
</div>
</div>
</div>
<ul class="wrap legend">
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="4" fill="#fff"/></svg>Station, published-orbit position</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="5" fill="none" stroke="#ff4fd8" stroke-width="2"/></svg>Spoofed signal at the measured speed</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><path d="M1 7h20" stroke="#ff4fd8" stroke-opacity=".6" stroke-width="3"/></svg>Gap along the track</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="6" fill="none" stroke="#fff" stroke-opacity=".6"/></svg>Above the horizon</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#3dffa0"/></svg>City: station and signal in view</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#ff4d5e"/></svg>City: one without the other</li>
</ul>
<main class="wrap grid">
<article class="panel" data-key="globe">
<header class="ph"><h2>Globe</h2><p>Sphere, radius 6,371 km</p></header>
<div class="map"><canvas aria-label="Globe: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="ae">
<header class="ph"><h2>North-pole AE</h2><p>Centred on the North Pole</p></header>
<div class="map"><canvas aria-label="North-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="dualpole">
<header class="ph"><h2>Dual-pole AE</h2><p>Centred on 0°N 0°E</p></header>
<div class="map"><canvas aria-label="Dual-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="mercator">
<header class="ph"><h2>Mercator</h2><p>True on the equator, edges joined</p></header>
<div class="map"><canvas aria-label="Mercator map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
</main>
<section class="wrap after">
<h2>Result</h2>
<p>On the globe the signal stays level with the station by construction: both come from the published orbit (numerically within {{GLOBE_DRIFT}} km over {{N_ORBITS}} orbits). On each flat map the station's track per orbit is longer than the {{SIG_TRACK}} km the signal covers in the same time: {{AE_TRACK}} km on the North-pole map, {{ME_TRACK}} km on Mercator and {{DP_TRACK}} km on the dual-pole map. One orbit after it is lined up, at whatever moment, the signal is {{AE_LAG1}} behind on the North-pole map, {{ME_LAG1}} on Mercator and {{DP_LAG1}} on the dual-pole map. Restarted level every minute instead, it is still up to {{AE_LAG60}} adrift on the North-pole map. The recordings show no such drift: in {{SN_N}} SatNOGS recordings of three passes on 3 October 2026, the S-curves are centred within {{SN_RMS}} s (rms per pass) of the closest approaches the published orbit predicts.</p>
<h3>Other ways to send the signal</h3>
<p><b>Corrected in real time</b> (<i>Real-time correction</i>, above): transmitters that track the station and steer the signal to where it was a latency earlier keep it that latency behind. To stay with the station the signal must then cross the map at the map speed, up to {{AE_XSIG}} times the {{V_SIG}} km/s its Doppler shift shows on the North-pole map. The recordings allow neither: their S-curves are centred within {{SN_RMS}} s of the predicted closest approaches, and their shifts give {{REC_V_SOUTH}} km/s over South America and Australia, where the North-pole map needs {{REC_AE_SOUTH}} km/s.</p>
<p><b>Ground transmitters timed to the station</b> can keep in step with it, but a transmitter sends one frequency to every receiver in range. At {{BURST_UTC}}, {{BURST_N}} receivers from Ireland to Belarus were recording the station's UHF signal at the same instant: {{BURST_UP}} heard it shifted up and {{BURST_DOWN}} down, from {{BURST_MIN}} to {{BURST_MAX}} kHz, each within about {{SN_OFFSET}} Hz of the shift the published orbit predicts for its own position. Imitating that takes a separate transmitter for each receiver, heard by no other. The signal also arrives from where the transmitter is, which a directional antenna or a pair of antennas measures, not from the station's place in the sky.</p>
<p><b>The station adjusting its own signal</b> over a flat map: the signal then arrives from the right direction, and the station can give one receiver the shift the globe predicts, but every other receiver hears the correction meant for that one. Over the North-pole map, adjusted for VK4JBE in Brisbane two minutes before closest approach on its highest pass in this window, it is {{ONE_E100}} Hz off for a receiver 100 km east and {{ONE_N1000}} kHz off 1,000 km north (437.55 MHz). Unadjusted, its shift would show the map speed: {{REC_AE_SOUTH}} km/s where the recordings from South America and Australia show {{REC_V_SOUTH}} km/s, and {{REC_AE_EU}} km/s where those from Europe show {{REC_V_EU}} km/s.</p>
<h2>Over {{N_ORBITS}} orbits</h2>
<div class="tw">
<table>
<thead><tr><th scope="col"></th><th scope="col">Globe</th><th scope="col">North-pole AE</th><th scope="col">Dual-pole AE</th><th scope="col">Mercator</th></tr></thead>
<tbody>
<tr><th scope="row">Map speed, km/s</th><td>{{G_VR}}</td><td>{{AE_VR}}</td><td>{{DP_VR}}</td><td>{{ME_VR}}</td></tr>
<tr><th scope="row">Fastest ÷ slowest, each orbit</th><td>{{G_RATIO}}</td><td>{{AE_RATIO}}</td><td>{{DP_RATIO}}</td><td>{{ME_RATIO}}</td></tr>
<tr><th scope="row">Speed gained and lost per orbit, km/s</th><td>{{G_SWING}}</td><td>{{AE_SWING}}</td><td>{{DP_SWING}}</td><td>{{ME_SWING}}</td></tr>
<tr><th scope="row">Largest speed change along the track, g</th><td>{{G_AMAX}}</td><td>{{AE_AMAX}}</td><td>{{DP_AMAX}}</td><td>{{ME_AMAX}}</td></tr>
<tr><th scope="row">Station's track per orbit, map km</th><td>{{G_TRACK}}</td><td>{{AE_TRACK}}</td><td>{{DP_TRACK}}</td><td>{{ME_TRACK}}</td></tr>
<tr><th scope="row">Signal's distance per orbit, map km</th><td>{{G_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td></tr>
<tr><th scope="row">Lag one orbit after lining up, any start</th><td>level</td><td>{{AE_LAG1}}</td><td>{{DP_LAG1}}</td><td>{{ME_LAG1}}</td></tr>
<tr><th scope="row">Gap one orbit after lining up, map km</th><td>{{GLOBE_DRIFT}}</td><td>{{AE_GAP1}}</td><td>{{DP_GAP1}}</td><td>{{ME_GAP1}}</td></tr>
<tr><th scope="row">Largest lag, restarted every minute</th><td>level</td><td>{{AE_LAG60}}</td><td>{{DP_LAG60}}</td><td>{{ME_LAG60}}</td></tr>
</tbody>
</table>
</div>
<h2>Method</h2>
<dl class="method">
<dt>Orbit</dt><dd>ISS (NORAD 25544) orbital elements from CelesTrak, epoch {{EPOCH_UTC}}; positions every second by SGP4 for {{N_ORBITS}} orbits from {{START_UTC}}. Period {{PERIOD}} min, mean altitude {{ALT}} km.</dd>
<dt>Measured speed</dt><dd>{{V_SIG}} km/s: the straight-line reading of the SatNOGS recordings, from the height of each S-curve with no map assumed (weighted mean of {{SN_N}} receivers). Over a plane this is the speed of a source in level flight and of the point beneath it; climbing or descending would make the source faster than the point beneath it, so level flight is the flat maps' best case. On the globe the same recordings fit the published orbit: {{V_ORBIT_RANGE}} km/s relative to the ground at {{ALT}} km, and {{GLOBE_V_RANGE}} km/s for the point beneath.</dd>
<dt>Map speed</dt><dd>Speed of the point beneath the station across the map, from positions 1 s apart, subdivided where a step exceeds 20 km on the map. Speed change: the rate of change of that speed over 10 s, along the track; turning adds to it. The ratio of fastest to slowest does not depend on the map's scale.</dd>
<dt>Signal</dt><dd>A source moving along the station's track on the map at {{V_SIG}} km/s from a moment when it is level with the station: each northbound equator crossing, or the moment chosen with <i>Line up signal now</i>, after which it runs without restarts. With <i>Real-time correction</i> the signal is instead placed where the station was the chosen latency earlier, so it moves at the map speed while its Doppler shift still shows {{V_SIG}} km/s. Signal speed: its speed across the map against the speed its Doppler shift shows. Lag: time since the station was at the signal's position. Gap: distance between them along the track, on the map. The one-orbit figures cover every start time at 1-min intervals over the {{N_ORBITS}} orbits.</dd>
<dt>In view</dt><dd>Within {{HORIZON_DEG}}° of arc ({{HORIZON_KM}} km) of the point beneath the station or the signal: the area over which the station is seen to rise and set, which is the horizon of a sphere at {{ALT}} km. Over a plane nothing would hide it: at {{ALT}} km it would stand {{EL_HORIZON}}° above the horizon at {{HORIZON_KM}} km and {{EL_10000}}° at 10,000 km. Cities: GeoNames, population one million or more, with places within 30 km of a larger one merged ({{N_CITIES}}).</dd>
<dt>Maps</dt><dd>As in the <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a>, except that Mercator's east and west edges are joined so that the track continues across 180°. On the dual-pole map the speed has no upper limit near 0°N 180°E, where the map's rim divides the Pacific; this window's closest pass, {{DP_RIM}}° away, needs {{DP_VMAX}} km/s.</dd>
</dl>
<h2>Sources</h2>
<p><a href="https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;FORMAT=TLE">CelesTrak GP elements</a> · <a href="https://pypi.org/project/sgp4/">sgp4</a> (Python) · <a href="https://www.naturalearthdata.com/">Natural Earth</a> 1:110m land · <a href="https://download.geonames.org/export/dump/">GeoNames</a> cities15000 · SatNOGS recordings of 3 October 2026 as analysed in <a href="https://distance.aethercosmology.com/#doppler">The Shape of Distance, Listening to the ISS</a> · <a href="https://spotthestation.nasa.gov/">NASA Spot the Station</a>.</p>
<button id="dl" type="button">Download the data (JSON)</button>
</section>
<footer class="wrap"><a href="https://github.com/aetherbird/cable-latency-survey">Code and data</a> (MIT, CC BY 4.0) · <a href="https://github.com/aetherbird/cable-latency-survey/blob/main/CONTRIBUTING.md">Contribute</a> · Related: <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a> · <a href="https://distance.aethercosmology.com">The Shape of Distance</a></footer>
<div id="tip" hidden></div>
<script>/*D3*/</script>
<script>
(() => {
"use strict";
const D = /*DATA*/null;
const RAD = Math.PI / 180, STEP = D.step, N = D.lat.length;
const NODES = D.nodes, NORB = NODES.length - 1, T_END = NODES[NORB];
const RE = 6371;
const VM = D.v_sig; // measured, km/s: level flight over a plane
const VG = D.vg_ref; // globe: the point beneath, from the same orbit
const COSH = Math.cos(D.horizon_deg * RAD);
const nf = new Intl.NumberFormat("en-US");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const C = { station: "#ffffff", signal: "#ff4fd8", track: "#3ee6ff", ok: "#3dffa0", bad: "#ff4d5e",
ocean: "#06101e", land: "#13243b", coast: "rgba(150,200,255,.22)", grat: "rgba(120,180,255,.08)", rim: "rgba(120,180,255,.3)",
city: "rgba(155,173,198,.32)", faint: "#65789a" };
// sample unit vectors
const UX = new Float64Array(N), UY = new Float64Array(N), UZ = new Float64Array(N);
for (let i = 0; i < N; i++) {
const p = D.lat[i] * RAD, l = D.lon[i] * RAD, c = Math.cos(p);
UX[i] = c * Math.cos(l); UY[i] = c * Math.sin(l); UZ[i] = Math.sin(p);
}
const clampT = t => Math.max(0, Math.min((N - 1) * STEP - 1e-6, t));
function uAt(t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
const a = UX[i] + (UX[i + 1] - UX[i]) * f, b = UY[i] + (UY[i + 1] - UY[i]) * f, c = UZ[i] + (UZ[i + 1] - UZ[i]) * f;
const n = Math.hypot(a, b, c);
return [a / n, b / n, c / n];
}
const lonlat = u => [Math.atan2(u[1], u[0]) / RAD, Math.asin(Math.max(-1, Math.min(1, u[2]))) / RAD];
function orbitOf(t) { let k = 0; while (k < NORB - 1 && t >= NODES[k + 1]) k++; return k; }
function at(arr, t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
return arr[i] + (arr[i + 1] - arr[i]) * f;
}
// signal: level with the station at the sync time, then VM km/s along the map track.
// syncT null: each northbound equator crossing; otherwise the moment chosen with "Line up signal now".
const SM = {};
for (const key of ["ae", "dualpole", "mercator"]) SM[key] = Float64Array.from(D.maps[key].S);
let syncT = null, corr = false, latency = 10;
const LATENCY = [0, 1, 2, 5, 10, 20, 30, 60, 120, 300];
function timeAtDistance(arr, km) {
let lo = 0, hi = arr.length - 1;
if (km <= arr[lo]) return 0;
if (km >= arr[hi]) return hi * STEP;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (arr[mid] <= km) lo = mid; else hi = mid; }
return (lo + (km - arr[lo]) / (arr[hi] - arr[lo])) * STEP;
}
function signalAt(key, t, k) {
const arr = SM[key];
if (corr) { // steered to where the station was `latency` s earlier: moves at the map speed
const L = Math.min(latency, t);
return { lag: L, gap: at(arr, t) - at(arr, t - L), v: at(D.maps[key].v, t - L) / 1000 };
}
const ts = syncT === null ? NODES[k] : syncT;
if (t <= ts) return { lag: 0, gap: 0, v: VM };
const target = at(arr, ts) + VM * (t - ts);
return { lag: t - timeAtDistance(arr, target), gap: at(arr, t) - target, v: VM };
}
// cities
const CITY = D.cities, NC = CITY.length;
const CX = new Float64Array(NC), CY = new Float64Array(NC), CZ = new Float64Array(NC);
CITY.forEach((c, j) => { const p = c[1] * RAD, l = c[2] * RAD; CX[j] = Math.cos(p) * Math.cos(l); CY[j] = Math.cos(p) * Math.sin(l); CZ[j] = Math.sin(p); });
const inView = (u, j) => u[0] * CX[j] + u[1] * CY[j] + u[2] * CZ[j] >= COSH;
// orbit tracks as [lon, lat] lists
const trackCache = {};
function track(k) {
if (trackCache[k]) return trackCache[k];
const pts = [lonlat(uAt(NODES[k]))];
for (let i = Math.ceil(NODES[k] / STEP); i * STEP < NODES[k + 1]; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(NODES[k + 1])));
return (trackCache[k] = pts);
}
function segment(t0, t1) {
if (t1 < t0) [t0, t1] = [t1, t0];
const pts = [lonlat(uAt(t0))];
for (let i = Math.ceil(t0 / STEP); i * STEP < t1; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(t1)));
return { type: "LineString", coordinates: pts };
}
// panels
const LAND = { type: "MultiPolygon", coordinates: D.land };
const GRAT = d3.geoGraticule().step([30, 30])();
const EQ = { type: "LineString", coordinates: d3.range(-180, 181, 2).map(l => [l, 0]) };
const SPH = { type: "Sphere" };
const panels = [...document.querySelectorAll(".panel")].map(el => {
const key = el.dataset.key;
const proj = key === "globe" ? d3.geoOrthographic().clipAngle(90)
: key === "ae" ? d3.geoAzimuthalEquidistant().rotate([0, -90]).clipAngle(180 - 1e-3)
: key === "dualpole" ? d3.geoAzimuthalEquidistant().rotate([0, 0]).clipAngle(180 - 1e-3)
: d3.geoMercator();
proj.precision(0.4);
const [cv, sp] = el.querySelectorAll("canvas");
const f = {}; el.querySelectorAll("[data-f]").forEach(d => (f[d.dataset.f] = d));
return { el, key, proj, cv, ctx: cv.getContext("2d"), sp, sctx: sp.getContext("2d"), f, W: 0, bg: null, cxy: null, state: new Uint8Array(NC), spK: -1, spImg: null };
});
let dpr = 1;
function layout() {
dpr = Math.min(2, window.devicePixelRatio || 1);
for (const p of panels) {
const W = p.cv.clientWidth;
if (!W) continue;
p.W = W; p.cv.width = Math.round(W * dpr); p.cv.height = Math.round(W * dpr);
const s = W / 2 - 3;
if (p.key === "globe") p.proj.scale(s).translate([W / 2, W / 2]);
else if (p.key === "mercator") p.proj.scale(W / (2 * Math.PI)).translate([W / 2, W / 2]);
else p.proj.scale(s / Math.PI).translate([W / 2, W / 2]);
if (p.key !== "globe") {
p.bg = document.createElement("canvas"); p.bg.width = p.cv.width; p.bg.height = p.cv.height;
const g = p.bg.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
base(g, p.proj);
p.cxy = CITY.map(c => p.proj([c[2], c[1]]));
g.fillStyle = C.city; g.beginPath();
for (const q of p.cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const sw = p.sp.clientWidth; p.sp.width = Math.round(sw * dpr); p.sp.height = Math.round(54 * dpr); p.spK = -1;
}
dirty = true;
}
function base(g, proj) {
const path = d3.geoPath(proj, g);
g.beginPath(); path(SPH); g.fillStyle = C.ocean; g.fill();
g.beginPath(); path(GRAT); g.strokeStyle = C.grat; g.lineWidth = 0.7; g.stroke();
g.beginPath(); path(EQ); g.strokeStyle = "rgba(120,180,255,.16)"; g.stroke();
g.beginPath(); path(LAND); g.fillStyle = C.land; g.fill(); g.strokeStyle = C.coast; g.lineWidth = 0.6; g.stroke();
g.beginPath(); path(SPH); g.strokeStyle = C.rim; g.lineWidth = 1; g.stroke();
}
// state at time t
function snapshot(t) {
const k = orbitOf(t), us = uAt(t), st = lonlat(us);
const m = {};
for (const key of ["ae", "dualpole", "mercator"]) {
const M = D.maps[key], sg = signalAt(key, t, k);
m[key] = { lag: sg.lag, gap: sg.gap, v: at(M.v, t) / 1000, a: at(M.a, t) / 1000, ug: uAt(t - sg.lag), vs: sg.v };
}
m.globe = { lag: 0, gap: 0, v: at(D.vglobe, t) / 1000, a: at(D.aglobe, t) / 1000, ug: us, vs: at(D.vglobe, t) / 1000 };
return { t, k, us, st, m };
}
function drawPanel(p, S, pulse) {
if (!p.W) return;
const g = p.ctx, W = p.W, proj = p.proj, path = d3.geoPath(proj, g), M = S.m[p.key];
g.setTransform(dpr, 0, 0, dpr, 0, 0);
if (p.key === "globe") {
proj.rotate([-S.st[0], -S.st[1] * 0.6]);
g.clearRect(0, 0, W, W);
base(g, proj);
} else {
g.clearRect(0, 0, W, W);
g.drawImage(p.bg, 0, 0, W, W);
}
const tr = track(S.k);
g.lineJoin = "round"; g.lineCap = "round";
g.beginPath(); path({ type: "LineString", coordinates: tr }); g.strokeStyle = "rgba(62,230,255,.22)"; g.lineWidth = 1; g.stroke();
g.beginPath(); path(segment(NODES[S.k], S.t)); g.strokeStyle = "rgba(62,230,255,.75)"; g.lineWidth = 1.5; g.stroke();
const sg = lonlat(M.ug);
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(segment(S.t - M.lag, S.t)); g.strokeStyle = "rgba(255,79,216,.55)"; g.lineWidth = 3.5; g.stroke();
}
// above-the-horizon circles
const circ = d3.geoCircle().radius(D.horizon_deg).precision(4);
g.beginPath(); path(circ.center(S.st)()); g.fillStyle = "rgba(255,255,255,.035)"; g.fill(); g.strokeStyle = "rgba(255,255,255,.5)"; g.lineWidth = 1; g.stroke();
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(circ.center(sg)()); g.fillStyle = "rgba(255,79,216,.05)"; g.fill(); g.strokeStyle = "rgba(255,79,216,.65)"; g.stroke();
}
// cities
let cxy = p.cxy;
if (p.key === "globe") {
const ctr = [S.st[0], S.st[1] * 0.6];
cxy = p.cxy = CITY.map(c => (d3.geoDistance([c[2], c[1]], ctr) < Math.PI / 2 - 0.01 ? proj([c[2], c[1]]) : null));
g.fillStyle = C.city; g.beginPath();
for (const q of cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const okP = new Path2D(), badP = new Path2D();
for (let j = 0; j < NC; j++) {
const s = inView(S.us, j), h = inView(M.ug, j), st = s && h ? 1 : s || h ? 2 : 0;
p.state[j] = st;
const q = cxy[j];
if (!st || !q) continue;
const P = st === 1 ? okP : badP, r = st === 1 ? 2.2 : 2.6;
P.moveTo(q[0] + r, q[1]); P.arc(q[0], q[1], r, 0, 7);
}
g.fillStyle = C.ok; g.fill(okP); g.fillStyle = C.bad; g.fill(badP);
// signal, then station
const qs = proj(S.st), qg = proj(sg);
const vis = ll => p.key !== "globe" || d3.geoDistance(ll, [S.st[0], S.st[1] * 0.6]) < Math.PI / 2;
if (qg && vis(sg)) {
if (!reduce) for (const ph of [pulse, (pulse + 0.5) % 1]) {
g.beginPath(); g.arc(qg[0], qg[1], 7 + 15 * ph, 0, 7); g.strokeStyle = `rgba(255,79,216,${(0.55 * (1 - ph)).toFixed(3)})`; g.lineWidth = 1.2; g.stroke();
}
g.beginPath(); g.arc(qg[0], qg[1], 6.5, 0, 7); g.strokeStyle = C.signal; g.lineWidth = 2.2; g.stroke();
}
if (qs) {
g.save(); g.shadowColor = "rgba(255,255,255,.9)"; g.shadowBlur = 10;
g.beginPath(); g.arc(qs[0], qs[1], 4.2, 0, 7); g.fillStyle = C.station; g.fill(); g.restore();
}
}
function drawSpark(p, S) {
const c = p.sctx, w = p.sp.width / dpr, h = 54, L = 30;
if (!w) return;
const k = S.k, t0 = NODES[k], t1 = NODES[k + 1];
const series = p.key === "globe" ? D.vglobe : D.maps[p.key].v;
if (p.spK !== k) {
// linear scale per panel, 0 to this orbit's top speed
let top = 0;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) top = Math.max(top, series[i] / 1000);
const nice = [10, 15, 20, 30, 50, 100, 150, 200, 300, 500, 700, 1000, 1500];
p.spTop = nice.find(n => n >= top * 1.05) || Math.ceil(top / 500) * 500;
const y = v => h - 3 - v / p.spTop * (h - 9);
const img = document.createElement("canvas"); img.width = p.sp.width; img.height = p.sp.height;
const g = img.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.font = "9.5px JetBrains Mono, monospace"; g.fillStyle = C.faint; g.textBaseline = "middle";
g.fillRect(L, Math.round(y(0)) + 0.5, w - L, 0.6); g.fillText("0", 0, y(0));
g.fillRect(L, Math.round(y(p.spTop)) + 0.5, w - L, 0.6); g.fillText(nf.format(p.spTop), 0, y(p.spTop));
const ref = p.key === "globe" ? VG : VM;
g.setLineDash([3, 3]); g.strokeStyle = "rgba(255,255,255,.55)"; g.lineWidth = 1;
g.beginPath(); g.moveTo(L, y(ref)); g.lineTo(w, y(ref)); g.stroke(); g.setLineDash([]);
const X = t => L + (t - t0) / (t1 - t0) * (w - L);
g.beginPath();
let first = true;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) { const xx = X(i * STEP), yy = y(series[i] / 1000); first ? g.moveTo(xx, yy) : g.lineTo(xx, yy); first = false; }
g.strokeStyle = C.track; g.lineWidth = 1.4; g.stroke();
p.spImg = img; p.spK = k; p.spY = y;
}
c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, p.sp.width, p.sp.height); c.drawImage(p.spImg, 0, 0);
c.setTransform(dpr, 0, 0, dpr, 0, 0);
const x = L + (S.t - t0) / (t1 - t0) * (w - L);
c.fillStyle = "rgba(255,255,255,.35)"; c.fillRect(x - 0.5, 0, 1, h);
c.beginPath(); c.arc(x, p.spY(S.m[p.key].v), 3, 0, 7); c.fillStyle = C.station; c.fill();
}
// readouts
const mmss = s => { s = Math.round(s); return s >= 60 ? `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s` : `${s} s`; };
const fv = v => (v >= 100 ? nf.format(Math.round(v)) : v.toFixed(2));
function speedHTML(v, ref) { // value with its ratio to the measured speed, tinted by tier
const r = v / ref, dev = Math.abs(r - 1), cls = dev <= 0.05 ? "ok" : dev <= 0.25 ? "warn" : "bad";
return `${fv(v)} km/s<span class="pill ${cls}">×${r >= 10 ? Math.round(r) : r.toFixed(2)}</span>`;
}
function readouts(S) {
for (const p of panels) {
const M = S.m[p.key], ref = p.key === "globe" ? VG : VM;
p.f.v.innerHTML = speedHTML(M.v, ref);
p.f.vs.innerHTML = speedHTML(M.vs, ref);
const a = M.a;
p.f.a.textContent = Math.abs(a) < 0.005 ? "0.00 g" : `${a > 0 ? "+" : "−"}${Math.abs(a) >= 100 ? nf.format(Math.round(Math.abs(a))) : Math.abs(a).toFixed(2)} g`;
if (p.key === "globe" || Math.abs(M.lag) < 0.5) p.f.s.textContent = "level with the station";
else p.f.s.textContent = `${mmss(Math.abs(M.lag))} ${M.lag > 0 ? "behind" : "ahead"} · ${nf.format(Math.round(Math.abs(M.gap)))} km`;
let now = 0;
for (let j = 0; j < NC; j++) if (p.state[j] === 2) now++;
p.f.c.textContent = String(now);
}
const d = new Date((D.epoch_unix + S.t) * 1000);
clock.textContent = d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
orbitEl.textContent = `Orbit ${S.k + 1} of ${NORB}`;
if (!dragging) range.value = String(Math.round(S.t));
}
// controls
const play = document.getElementById("play"), icon = document.getElementById("playicon");
const range = document.getElementById("time"), clock = document.getElementById("clock"), orbitEl = document.getElementById("orbit");
range.max = String(Math.floor(T_END));
let t = Math.min(D.t_init, T_END - 1), rate = 60, playing = !reduce, dirty = true, dragging = false, lastRO = 0;
function setPlaying(on) {
playing = on;
icon.setAttribute("d", on ? "M2 1h3.5v12H2zM8.5 1H12v12H8.5z" : "M2.5 1l10 6-10 6z");
play.setAttribute("aria-label", on ? "Pause" : "Play");
dirty = true;
}
setPlaying(playing);
play.addEventListener("click", () => setPlaying(!playing));
document.querySelectorAll(".rates button").forEach(b => b.addEventListener("click", () => {
rate = +b.dataset.rate;
document.querySelectorAll(".rates button").forEach(o => o.setAttribute("aria-pressed", String(o === b)));
}));
range.addEventListener("input", () => { t = +range.value; dirty = true; });
const syncBtn = document.getElementById("sync"), syncAuto = document.getElementById("syncauto"), syncInfo = document.getElementById("syncinfo");
syncBtn.addEventListener("click", () => {
syncT = t; syncAuto.hidden = false;
syncInfo.textContent = "Lined up: " + new Date((D.epoch_unix + t) * 1000).toISOString().slice(11, 19) + " UTC";
dirty = true;
});
const corrBtn = document.getElementById("corr"), latIn = document.getElementById("lat"), latV = document.getElementById("latv"), latWrap = document.getElementById("latwrap");
let syncText = "";
corrBtn.addEventListener("click", () => {
corr = !corr;
corrBtn.setAttribute("aria-pressed", String(corr));
latWrap.hidden = !corr; syncBtn.disabled = corr; syncAuto.disabled = corr;
if (corr) { syncText = syncInfo.textContent; syncInfo.textContent = "Corrected continuously"; }
else syncInfo.textContent = syncText;
dirty = true;
});
latIn.addEventListener("input", () => { latency = LATENCY[+latIn.value]; latV.textContent = latency >= 60 ? `${latency / 60} min` : `${latency} s`; dirty = true; });
syncAuto.addEventListener("click", () => {
syncT = null; syncAuto.hidden = true;
syncInfo.textContent = "Lined up: each northbound equator crossing";
dirty = true;
});
range.addEventListener("pointerdown", () => (dragging = true));
window.addEventListener("pointerup", () => (dragging = false));
document.addEventListener("keydown", e => {
if (e.code === "Space" && !/^(INPUT|BUTTON|SELECT|TEXTAREA|A)$/.test(e.target.tagName)) { e.preventDefault(); setPlaying(!playing); }
});
for (const p of panels) {
const seek = e => {
const r = p.sp.getBoundingClientRect(), k = orbitOf(t);
const f = Math.max(0, Math.min(1, (e.clientX - r.left - 30) / (r.width - 30)));
t = NODES[k] + f * (NODES[k + 1] - NODES[k]) - 1e-3; dirty = true;
};
p.sp.addEventListener("pointerdown", e => { seek(e); p.sp.setPointerCapture(e.pointerId); });
p.sp.addEventListener("pointermove", e => { if (e.buttons) seek(e); });
}
// city tooltip
const tip = document.getElementById("tip");
const STATE = (p, j) => {
if (p.key === "globe") return p.state[j] ? "Station and signal in view" : "Neither in view";
const S = lastS, s = inView(S.us, j), h = inView(S.m[p.key].ug, j);
return s && h ? "Station and signal in view" : s ? "Station in view, no signal" : h ? "Signal, no station in view" : "Neither in view";
};
let lastS = null;
for (const p of panels) {
p.cv.addEventListener("pointermove", e => {
if (!p.cxy || !lastS) return;
const r = p.cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
let best = -1, bd = 81;
p.cxy.forEach((q, j) => { if (q) { const d = (q[0] - x) ** 2 + (q[1] - y) ** 2; if (d < bd) { bd = d; best = j; } } });
if (best < 0) { tip.hidden = true; return; }
tip.innerHTML = `<b>${CITY[best][0]}</b>, ${CITY[best][3]}<span>${STATE(p, best)}</span>`;
tip.hidden = false;
const tw = tip.offsetWidth, th = tip.offsetHeight;
tip.style.left = Math.min(window.innerWidth - tw - 8, e.clientX + 14) + "px";
tip.style.top = Math.max(8, e.clientY - th - 10) + "px";
});
p.cv.addEventListener("pointerleave", () => (tip.hidden = true));
}
// data download
document.getElementById("dl").addEventListener("click", () => {
const { land, ...rest } = D;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([JSON.stringify(rest)], { type: "application/json" }));
a.download = "sync_test_data.json"; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
// loop
let last = performance.now();
function frame(now) {
const dt = Math.min(0.25, (now - last) / 1000); last = now;
if (playing) { t += dt * rate; if (t >= T_END) t -= T_END; dirty = true; }
if (dirty) {
const S = snapshot(t), pulse = (now / 1600) % 1;
lastS = S;
for (const p of panels) { drawPanel(p, S, pulse); drawSpark(p, S); }
if (now - lastRO > 120 || !playing) { readouts(S); lastRO = now; }
dirty = playing;
}
requestAnimationFrame(frame);
}
new ResizeObserver(() => layout()).observe(document.querySelector(".grid"));
layout();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+11 -7
View File
@@ -222,7 +222,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<div class="hero" id="hero">
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
<div class="hud tl">
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
<h1>Cable Latency Survey</h1>
<p>Measured round-trip times between cities, compared with the minimum propagation time along submarine-cable routes on four models of the Earth's surface.</p>
</div>
@@ -328,7 +327,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<section>
<h2 class="sec">Code and data</h2>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed.</p>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed. Corrections, new measurements and re-analyses are welcome, including ones that disagree with the results.</p>
<div class="dl" id="code"></div>
<pre id="repro"></pre>
</section>
@@ -353,7 +352,7 @@ const MNAME=Object.fromEntries(MODELS),NK={globe:"globe",ae:"ae",dp:"dualpole",m
const reduce=matchMedia("(prefers-reduced-motion: reduce)").matches;
const S={src:"all",view:"ae",sel:null,hover:null,site:null,onlyBad:false,pulses:!reduce,mode:"cable",tab:"route"};
const MODE={cable:"sea-cable route",line:"straight line"};
const GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const GH="https://github.com/aetherbird/cable-latency-survey",GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const lnk=(href,t)=>`<a href="${href}" target="_blank" rel="noopener">${t}</a>`;
const netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
@@ -605,8 +604,12 @@ function readout(){
function segDist(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy;let t=l?((px-a[0])*dx+(py-a[1])*dy)/l:0;t=Math.max(0,Math.min(1,t));return Math.hypot(px-a[0]-t*dx,py-a[1]-t*dy)}
function nearest(mx,my){
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
let best=null,bd=7;for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<bd){bd=d;best={pair:i}}}
return best;
const dm=new Map();for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<7&&d<(dm.get(i)??1e9))dm.set(i,d)}
if(!dm.size)return null;
/* routes sharing a cable overlap: take the one drawn on top (selected, then red, orange, cyan) */
const m=Math.min(...dm.values());let best=null,bp=-1;
for(const [i,d] of dm){if(d>m+1.5)continue;const pr=i===S.sel?9:rt(LP[i]).tier;if(pr>bp||(pr===bp&&d<dm.get(best))){bp=pr;best=i}}
return{pair:best};
}
let drag=null,hoverRAF=false;
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
@@ -911,7 +914,8 @@ function renderCode(){
["analyze_wn.py","WonderNetwork archive: statistics, mislocated servers"],["cables.py","published cable lengths on each model"],["network.py","cable-and-land routing on each model"],["trace.py","RIPE Atlas sea crossings"],
["asmap.py","network attribution from public BGP data"],["pingmap.py","shape fit without positions"],["build.py","this page"],["export.py","JSON data hand-off"]];
document.getElementById("code").innerHTML=`
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))}</div>
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))} · mirror ${lnk(GH,GH.replace("https://",""))}</div>
<div>Contribute</div><div>${lnk(GH+"/blob/main/CONTRIBUTING.md","How to contribute")} · ${lnk(GH+"/issues","issues")} · ${lnk(GH+"/pulls","pull requests")}</div>
<div>Data hand-off</div><div>${lnk(RAWV+"cable_latency_survey_data_v2.json","cable_latency_survey_data_v2.json")} (every measurement, site, route and cable path) · ${lnk(RAWV+"cable_latency_survey_data.json","version 1")}</div>
<div>Globalping</div><div>${gp.map((r,i)=>f(r.file,`run ${i+1}`)).join(" · ")}: raw results; each carries its Globalping measurement ID</div>
<div>Vultr servers</div><div>${vps.map(v=>`${v}: ${f(`data/vps/${v}/ping.log`,"ping")}, ${f(`data/vps/${v}/mtr.log`,"traceroute")}`).join(" · ")}</div>
@@ -989,7 +993,7 @@ const ia=LS.findIndex(s=>s.name==="Sydney"),ib=LS.findIndex(s=>s.name==="Aucklan
S.sel=LP.find(p=>(p.a===ia&&p.b===ib)||(p.a===ib&&p.b===ia)).idx;
document.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
document.getElementById("srcsel").value=S.src;
{document.getElementById("eyeb").textContent=`${LS.length} sites · ${Object.keys(D.netlines).length} cables · 4 models`;
{
document.getElementById("step1").textContent=`Globalping probes, rented Vultr servers and a residential connection measured round-trip times to RIPE Atlas anchors and to each other across ${LS.length} sites. The minimum for each pair is used.`}
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
+11 -7
View File
@@ -214,7 +214,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<div class="hero" id="hero">
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
<div class="hud tl">
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
<h1>Cable Latency Survey</h1>
<p>Measured round-trip times between cities, compared with the minimum propagation time along submarine-cable routes on four models of the Earth's surface.</p>
</div>
@@ -320,7 +319,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
<section>
<h2 class="sec">Code and data</h2>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed.</p>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed. Corrections, new measurements and re-analyses are welcome, including ones that disagree with the results.</p>
<div class="dl" id="code"></div>
<pre id="repro"></pre>
</section>
@@ -343,7 +342,7 @@ const MNAME=Object.fromEntries(MODELS),NK={globe:"globe",ae:"ae",dp:"dualpole",m
const reduce=matchMedia("(prefers-reduced-motion: reduce)").matches;
const S={src:"all",view:"ae",sel:null,hover:null,site:null,onlyBad:false,pulses:!reduce,mode:"cable",tab:"route"};
const MODE={cable:"sea-cable route",line:"straight line"};
const GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const GH="https://github.com/aetherbird/cable-latency-survey",GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const lnk=(href,t)=>`<a href="${href}" target="_blank" rel="noopener">${t}</a>`;
const netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
@@ -595,8 +594,12 @@ function readout(){
function segDist(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy;let t=l?((px-a[0])*dx+(py-a[1])*dy)/l:0;t=Math.max(0,Math.min(1,t));return Math.hypot(px-a[0]-t*dx,py-a[1]-t*dy)}
function nearest(mx,my){
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
let best=null,bd=7;for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<bd){bd=d;best={pair:i}}}
return best;
const dm=new Map();for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<7&&d<(dm.get(i)??1e9))dm.set(i,d)}
if(!dm.size)return null;
/* routes sharing a cable overlap: take the one drawn on top (selected, then red, orange, cyan) */
const m=Math.min(...dm.values());let best=null,bp=-1;
for(const [i,d] of dm){if(d>m+1.5)continue;const pr=i===S.sel?9:rt(LP[i]).tier;if(pr>bp||(pr===bp&&d<dm.get(best))){bp=pr;best=i}}
return{pair:best};
}
let drag=null,hoverRAF=false;
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
@@ -901,7 +904,8 @@ function renderCode(){
["analyze_wn.py","WonderNetwork archive: statistics, mislocated servers"],["cables.py","published cable lengths on each model"],["network.py","cable-and-land routing on each model"],["trace.py","RIPE Atlas sea crossings"],
["asmap.py","network attribution from public BGP data"],["pingmap.py","shape fit without positions"],["build.py","this page"],["export.py","JSON data hand-off"]];
document.getElementById("code").innerHTML=`
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))}</div>
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))} · mirror ${lnk(GH,GH.replace("https://",""))}</div>
<div>Contribute</div><div>${lnk(GH+"/blob/main/CONTRIBUTING.md","How to contribute")} · ${lnk(GH+"/issues","issues")} · ${lnk(GH+"/pulls","pull requests")}</div>
<div>Data hand-off</div><div>${lnk(RAWV+"cable_latency_survey_data_v2.json","cable_latency_survey_data_v2.json")} (every measurement, site, route and cable path) · ${lnk(RAWV+"cable_latency_survey_data.json","version 1")}</div>
<div>Globalping</div><div>${gp.map((r,i)=>f(r.file,`run ${i+1}`)).join(" · ")}: raw results; each carries its Globalping measurement ID</div>
<div>Vultr servers</div><div>${vps.map(v=>`${v}: ${f(`data/vps/${v}/ping.log`,"ping")}, ${f(`data/vps/${v}/mtr.log`,"traceroute")}`).join(" · ")}</div>
@@ -979,7 +983,7 @@ const ia=LS.findIndex(s=>s.name==="Sydney"),ib=LS.findIndex(s=>s.name==="Aucklan
S.sel=LP.find(p=>(p.a===ia&&p.b===ib)||(p.a===ib&&p.b===ia)).idx;
document.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
document.getElementById("srcsel").value=S.src;
{document.getElementById("eyeb").textContent=`${LS.length} sites · ${Object.keys(D.netlines).length} cables · 4 models`;
{
document.getElementById("step1").textContent=`Globalping probes, rented Vultr servers and a residential connection measured round-trip times to RIPE Atlas anchors and to each other across ${LS.length} sites. The minimum for each pair is used.`}
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
+87
View File
@@ -0,0 +1,87 @@
// The Best Flat Map (femap/site/index.html): errors, overflow, controls clickable, score filled, at 8 viewports;
// then the solver: it reduces the broken count from the North-pole AE start, data toggles change the count,
// and the 3D release closes round.
// CH=<chromium> node femap.mjs [url] (default: the built file; LIVE=1 for https://fe-map.aethercosmology.com)
import { chromium } from 'playwright-core';
import { fileURLToPath } from 'url';
import path from 'path';
const here = path.dirname(fileURLToPath(import.meta.url));
const url = process.argv[2] || (process.env.LIVE ? 'https://fe-map.aethercosmology.com/' : 'file://' + path.join(here, '../femap/site/index.html'));
const VP = [[1920, 1080], [1680, 1050], [1440, 900], [1280, 720], [1024, 768], [820, 1180], [390, 844], [360, 740]];
const b = await chromium.launch({ executablePath: process.env.CH });
let fails = 0;
const fail = (...a) => { fails++; console.log('FAIL', ...a); };
for (const [w, h] of VP) {
const p = await b.newPage({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
const errs = [];
p.on('pageerror', e => errs.push(e.message));
p.on('console', m => m.type() === 'error' && errs.push(m.text()));
await p.goto(url, { waitUntil: 'load' });
await p.waitForTimeout(2500);
const r = await p.evaluate(() => {
const out = { overflow: document.documentElement.scrollWidth - innerWidth, blocked: [], empty: [], clipped: [] };
for (const el of document.querySelectorAll('button, input, a')) {
el.scrollIntoView({ block: 'center' });
const b = el.getClientRects()[0];
if (!b || !b.width) continue;
const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
if (hit !== el && !el.contains(hit) && !(el.tagName === 'INPUT' && hit && hit.closest('label') === el.closest('label')))
out.blocked.push((el.id || el.textContent.trim().slice(0, 20)) + ' <- ' + (hit && (hit.id || hit.className || hit.tagName)));
}
if (document.querySelectorAll('#score tr').length < 6) out.empty.push('score');
if (!document.querySelector('#bands tbody tr')) out.empty.push('bands');
for (const c of document.querySelectorAll('canvas')) if (!c.width || !c.height) out.empty.push('canvas ' + c.id);
for (const td of document.querySelectorAll('#score td')) if (td.scrollWidth > td.clientWidth + 1) out.clipped.push(td.textContent);
for (const el of document.querySelectorAll('.worst b')) if (el.scrollWidth > el.clientWidth + 1 && el.closest('li').getBoundingClientRect().width < 120) out.clipped.push(el.textContent);
window.scrollTo(0, 0);
return out;
});
if (errs.length) fail(w, h, 'errors', errs.slice(0, 3));
if (r.overflow > 0) fail(w, h, 'horizontal overflow', r.overflow);
if (r.blocked.length) fail(w, h, 'blocked', r.blocked.slice(0, 5));
if (r.empty.length) fail(w, h, 'empty', r.empty);
if (r.clipped.length) fail(w, h, 'clipped', r.clipped.slice(0, 4));
await p.screenshot({ path: path.join(here, `out/femap_${w}x${h}.png`) });
await p.close();
}
// behaviour
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
const errs = []; p.on('pageerror', e => errs.push(e.message));
await p.goto(url, { waitUntil: 'load' });
const broken = () => p.evaluate(() => +(document.getElementById('status').textContent.match(/broken ([\d,]+)/) || [0, 'NaN'])[1].replace(/,/g, ''));
await p.waitForTimeout(300);
const k0 = await broken();
await p.waitForFunction(() => /^Done/.test(document.getElementById('status').textContent), null, { timeout: 120000 });
const k1 = await broken();
console.log('AE start: broken', k0, '->', k1);
if (!(k1 < k0)) fail('solver did not reduce the broken count', k0, k1);
if (k1 > 60) fail('live solve far from the offline best', k1);
await p.screenshot({ path: path.join(here, 'out/femap_solved.png') });
// best of N: loads without solving and matches the build
await p.click('#starts button[data-start="best"]');
await p.waitForTimeout(400);
const kb = await broken();
const want = await p.evaluate(() => D.stats.best.broken);
console.log('Best start: broken', kb, 'build says', want);
if (kb !== want) fail('best layout count differs from the build', kb, want);
// flights off: the plane can satisfy limits alone by shrinking
await p.click('#d-fl');
await p.waitForFunction(() => /^Done/.test(document.getElementById('status').textContent), null, { timeout: 120000 });
const kf = await broken();
console.log('Limits only: broken', kf);
if (kf > 5) fail('limits alone should be (nearly) satisfiable on a plane', kf);
await p.click('#d-fl');
await p.click('#starts button[data-start="best"]');
await p.waitForTimeout(300);
// 3D release
await p.click('#lift');
await p.waitForFunction(() => document.getElementById('r3s').textContent === 'done', null, { timeout: 180000 });
const r3 = await p.evaluate(() => ({ b: document.getElementById('r3b').textContent, w: document.getElementById('r3w').textContent, r: document.getElementById('r3r').textContent, f: document.getElementById('r3f').textContent }));
console.log('3D', JSON.stringify(r3));
if (!(parseInt(r3.w) > 120)) fail('3D release did not wrap round', r3.w);
if (!(+r3.b.replace(/,/g, '') < +r3.f.replace(/,/g, ''))) fail('3D not better than the plane', r3);
await p.locator('.lift').screenshot({ path: path.join(here, 'out/femap_3d.png') });
if (errs.length) fail('errors', errs.slice(0, 3));
console.log(fails ? `femap: ${fails} FAILS` : 'femap OK');
await b.close();
process.exit(fails ? 1 : 0);
+7
View File
@@ -0,0 +1,7 @@
import { chromium } from 'playwright-core';
import { PAGE } from './paths.mjs';
const b = await chromium.launch({ executablePath: process.env.CH }); const errs = [];
for (const [w, h] of [[1440, 900], [390, 844]]) { const p = await b.newPage({ viewport: { width: w, height: h } }); p.on('pageerror', e => errs.push(e.message));
await p.goto(PAGE('site/index.html')); await p.waitForTimeout(1500); await p.screenshot({ path: `out/hero_${w}.png` });
const t = await p.evaluate(() => [document.querySelector('.tl').innerText.slice(0, 50), document.getElementById('step1').textContent.slice(0, 40), document.documentElement.scrollWidth]); console.log(w, JSON.stringify(t)); await p.close() }
console.log('ERRORS', JSON.stringify(errs)); await b.close();
+4 -12
View File
@@ -1,5 +1,4 @@
// New sections: no errors; flat panels at zero correction equal the page's straight-line counts for every source;
// the last slider stop leaves nothing inconsistent; screenshots of each section.
// Networks and code sections: no errors, no horizontal overflow, screenshots; the flat-fit section stays removed.
import { chromium } from 'playwright-core';
import { PAGE, HAND } from './paths.mjs';
const b = await chromium.launch({ executablePath: process.env.CH });
@@ -8,18 +7,11 @@ for (const [w, h] of [[1440, 900], [390, 844]]) {
const p = await b.newPage({ viewport: { width: w, height: h } });
p.on('pageerror', e => errs.push(`${w}: ${e.message}`)); p.on('console', m => { if (m.type() === 'error' && !/net::|ERR_/.test(m.text())) errs.push(`${w}: ${m.text()}`) });
await p.goto(PAGE('site/index.html')); await p.waitForTimeout(1500);
for (const src of ['all', 'ours', 'gp', 'home']) {
await p.selectOption('#srcsel', src); await p.waitForTimeout(150);
const r = await p.evaluate(() => { const out = {};
for (const [m, , k] of FFM) { const f = D.flat.fits[S.src][k]; out[m] = [f.budgets[0].inconsistent.length, liveBad(m, 'line'), f.budgets[f.budgets.length - 1].inconsistent.length, f.routes, LP.length] }
return out });
for (const [m, [b0, page, last, nr, lp]] of Object.entries(r)) if (b0 !== page || last !== 0 || nr !== lp) fails.push(`${w} ${src} ${m}: fit ${b0} page ${page} last ${last} routes ${nr}/${lp}`);
}
if (await p.locator('h2.sec', { hasText: 'flat arrangements' }).count()) fails.push(`${w}: flat-fit section present`);
await p.selectOption('#srcsel', 'all'); await p.waitForTimeout(200);
for (const [sec, name] of [['Networks on the measured paths', 'net'], ['Best attainable flat arrangements', 'ff'], ['Code and data', 'code']]) {
for (const [sec, name] of [['Networks on the measured paths', 'net'], ['Code and data', 'code']]) {
const el = p.locator('h2.sec', { hasText: sec }).locator('xpath=..');
if (name === 'ff') for (const v of [0, 4, 6]) { await p.fill('#ffb', String(v)); await p.dispatchEvent('#ffb', 'input'); await p.waitForTimeout(250); await el.screenshot({ path: `out/sec_${name}${v}_${w}.png` }) }
else await el.screenshot({ path: `out/sec_${name}_${w}.png` });
await el.screenshot({ path: `out/sec_${name}_${w}.png` });
}
const sw = await p.evaluate(() => document.documentElement.scrollWidth);
if (sw !== w) fails.push(`${w}: page scrollWidth ${sw}`);
+15
View File
@@ -0,0 +1,15 @@
// Clicking a red line where routes overlap selects the red route, not one hidden beneath it.
import { chromium } from 'playwright-core';
import { PAGE } from './paths.mjs';
const b = await chromium.launch({ executablePath: process.env.CH }); const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
const errs = []; p.on('pageerror', e => errs.push(e.message));
await p.goto(PAGE('site/index.html')); await p.waitForTimeout(1800);
const pt = await p.evaluate(() => { const ia = LS.findIndex(s => s.name === 'Fortaleza'), ib = LS.findIndex(s => s.name === 'Luanda');
const q = LP.find(x => (x.a === ia && x.b === ib) || (x.a === ib && x.b === ia)); const segs = hits.filter(h => h[0] === q.idx).map(h => h[1]).sort((x, y) => y.length - x.length)[0];
const k = Math.floor(segs.length / 2), r = document.getElementById('cv').getBoundingClientRect(); return { x: r.left + segs[k][0], y: r.top + segs[k][1], want: LS[q.a].name + '–' + LS[q.b].name } });
await p.mouse.move(pt.x, pt.y); await p.waitForTimeout(250);
const tip = (await p.innerText('#tip')).replace(/\s+/g, ' ');
await p.mouse.click(pt.x, pt.y); await p.waitForTimeout(300);
const got = await p.evaluate(() => [LS[LP[S.sel].a].name + '–' + LS[LP[S.sel].b].name, rt(LP[S.sel]).tier]);
console.log('clicked SACS line; selected', got, 'expected', pt.want, '| tooltip:', tip.slice(0, 200));
console.log(got[1] === 2 ? 'OK: top (red) route selected' : 'FAIL', 'ERRORS', JSON.stringify(errs)); await b.close();
+5 -1
View File
@@ -13,6 +13,10 @@ node card_sel.mjs # every route x map x size: no
node trunc.mjs | tail -1 # no truncated text in the route card
node view_btns.mjs | tail -2 # zoom / reset buttons
node offline.mjs | tail -3 # site/index.html with the network blocked
node newsec.mjs | tail -2 # networks, flat arrangements, code: counts and layout
node newsec.mjs | tail -2 # networks and code sections: layout, no flat-fit section
node overlap.mjs | tail -1 # a click where routes overlap selects the top-drawn route
node hero.mjs | tail -1 # hero renders at desktop and phone width
node mode.mjs | cut -c1-60 # cable / straight toggle changes the counts
node sync.mjs | tail -1 # The Sync Test (satellites/): 8 viewports, readouts, controls
[ "${LIVE:-0}" = 1 ] && node live.mjs # LIVE=1: the public site
node femap.mjs | tail -1 # The Best Flat Map (femap/): 8 viewports, live solve, 3D release
+99
View File
@@ -0,0 +1,99 @@
// The Sync Test (satellites/site/index.html): errors, overflow, controls clickable, readouts filled, at 8 viewports.
// CH=<chromium> node sync.mjs [url] (default: the built file; LIVE=1 for https://iss.aethercosmology.com)
import { chromium } from 'playwright-core';
import { fileURLToPath } from 'url';
import path from 'path';
const here = path.dirname(fileURLToPath(import.meta.url));
const url = process.argv[2] || (process.env.LIVE ? 'https://iss.aethercosmology.com/' : 'file://' + path.join(here, '../satellites/site/index.html'));
const VP = [[1920, 1080], [1680, 1050], [1440, 900], [1280, 720], [1024, 768], [820, 1180], [390, 844], [360, 740]];
const b = await chromium.launch({ executablePath: process.env.CH });
let fails = 0;
const fail = (...a) => { fails++; console.log('FAIL', ...a); };
for (const [w, h] of VP) {
const p = await b.newPage({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
const errs = [];
p.on('pageerror', e => errs.push(e.message));
p.on('console', m => m.type() === 'error' && errs.push(m.text()));
await p.goto(url, { waitUntil: 'load' });
await p.waitForTimeout(1500);
const r = await p.evaluate(() => {
const out = { overflow: document.documentElement.scrollWidth - innerWidth, blocked: [], empty: [], clipped: [] };
for (const el of document.querySelectorAll('button, input, a')) {
el.scrollIntoView({ block: 'center' });
const b = el.getClientRects()[0]; // first line box: inline links can wrap
if (!b || !b.width) continue;
const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
if (hit !== el && !el.contains(hit)) out.blocked.push((el.id || el.textContent.trim().slice(0, 20)) + ' <- ' + (hit && (hit.id || hit.className || hit.tagName)));
}
for (const dd of document.querySelectorAll('.ro dd')) {
if (/^—?$/.test(dd.textContent.trim())) out.empty.push(dd.closest('.panel').dataset.key + ':' + dd.dataset.f);
if (dd.scrollWidth > dd.clientWidth + 1) out.clipped.push(dd.closest('.panel').dataset.key + ':' + dd.dataset.f + ' "' + dd.textContent + '"');
}
for (const c of document.querySelectorAll('.map canvas')) if (!c.width) out.empty.push('canvas');
window.scrollTo(0, 0);
return out;
});
if (errs.length) fail(w, h, 'errors', errs.slice(0, 3));
if (r.overflow > 0) fail(w, h, 'horizontal overflow', r.overflow);
if (r.blocked.length) fail(w, h, 'blocked', r.blocked.slice(0, 5));
if (r.empty.length) fail(w, h, 'empty', r.empty);
if (r.clipped.length) fail(w, h, 'clipped', r.clipped);
await p.screenshot({ path: path.join(here, `out/sync_${w}x${h}.png`) });
await p.close();
}
// behaviour: pause, seek late in orbit 1, check readouts against the data
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
await p.goto(url, { waitUntil: 'load' });
await p.waitForTimeout(500);
const chk = await p.evaluate(async () => {
document.getElementById('play').click();
const r = document.getElementById('time'); r.value = '5000'; r.dispatchEvent(new Event('input'));
await new Promise(f => setTimeout(f, 400));
const g = k => [...document.querySelectorAll(`.panel[data-key="${k}"] .ro dd`)].map(d => d.textContent);
return { globe: g('globe'), ae: g('ae'), dp: g('dualpole'), me: g('mercator'), clock: document.getElementById('clock').textContent, orbit: document.getElementById('orbit').textContent, play: document.getElementById('play').getAttribute('aria-label') };
});
console.log(JSON.stringify(chk));
if (!/level/.test(chk.globe[2])) fail('globe signal not level', chk.globe[2]);
if (!/behind/.test(chk.ae[2])) fail('AE signal not behind at t=5000', chk.ae[2]);
if (chk.play !== 'Play') fail('pause did not toggle');
await p.screenshot({ path: path.join(here, 'out/sync_t5000.png') });
// "Line up signal now": level at once, drifts after, before the chosen moment level; "Each orbit" restores the default
const lu = await p.evaluate(async () => {
const r = document.getElementById('time'), wait = () => new Promise(f => setTimeout(f, 300));
const ae = () => document.querySelector('.panel[data-key="ae"] [data-f="s"]').textContent;
const go = v => { r.value = String(v); r.dispatchEvent(new Event('input')); };
go(20000); await wait();
document.getElementById('sync').click(); await wait();
const at0 = ae(), info = document.getElementById('syncinfo').textContent;
go(20600); await wait(); const after = ae();
go(19000); await wait(); const before = ae();
document.getElementById('syncauto').click(); go(20600); await wait();
return { at0, info, after, before, auto: ae(), infoAuto: document.getElementById('syncinfo').textContent, autoHidden: document.getElementById('syncauto').hidden };
});
console.log(JSON.stringify(lu));
if (!/level/.test(lu.at0)) fail('not level right after lining up', lu.at0);
if (!/behind|ahead/.test(lu.after)) fail('no drift 10 min after lining up', lu.after);
if (!/level/.test(lu.before)) fail('not level before the chosen moment', lu.before);
if (!/UTC/.test(lu.info) || !/equator/.test(lu.infoAuto) || !lu.autoHidden) fail('line-up status text', lu.info, lu.infoAuto);
if (lu.auto === lu.after) fail('Each orbit did not restore the default');
// "Real-time correction": the signal trails by the latency and moves at the map speed; its Doppler still says the measured speed
const rc = await p.evaluate(async () => {
const r = document.getElementById('time'), lat = document.getElementById('lat'), wait = () => new Promise(f => setTimeout(f, 300));
const q = (k, f) => document.querySelector(`.panel[data-key="${k}"] [data-f="${f}"]`).textContent;
r.value = '5000'; r.dispatchEvent(new Event('input'));
document.getElementById('corr').click(); await wait();
const l10 = q('ae', 's'), vs10 = q('ae', 'vs'), syncDisabled = document.getElementById('sync').disabled;
lat.value = '0'; lat.dispatchEvent(new Event('input')); await wait();
const l0 = q('ae', 's'), vs0 = q('ae', 'vs'), v0 = q('ae', 'v'), g0 = q('globe', 'vs');
document.getElementById('corr').click(); await wait();
return { l10, vs10, syncDisabled, l0, vs0, v0, g0, off: q('ae', 's'), offvs: q('ae', 'vs'), info: document.getElementById('syncinfo').textContent };
});
console.log(JSON.stringify(rc));
if (!/^10 s behind/.test(rc.l10)) fail('correction latency 10 s not shown', rc.l10);
if (!/level/.test(rc.l0) || rc.vs0 !== rc.v0) fail('zero latency: signal should be level and move at the map speed', rc.l0, rc.vs0, rc.v0);
if (/×1\.00/.test(rc.vs0)) fail('corrected signal speed should differ from its Doppler on the AE map', rc.vs0);
if (!/×1\.00/.test(rc.offvs) || !/×1\.00/.test(rc.g0)) fail('uncorrected / globe signal speed should equal its Doppler', rc.offvs, rc.g0);
if (!rc.syncDisabled || /Corrected/.test(rc.info)) fail('line-up controls not disabled / restored', rc.syncDisabled, rc.info);
await b.close();
console.log(fails ? `${fails} failures` : 'sync OK');
process.exit(fails ? 1 : 0);