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:
@@ -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
@@ -10,7 +10,9 @@ portions listed below. This covers:
|
|||||||
- `src/data/` (Globalping results, Vultr and residential logs, routes, fits,
|
- `src/data/` (Globalping results, Vultr and residential logs, routes, fits,
|
||||||
network attribution);
|
network attribution);
|
||||||
- `cable_latency_survey_data.json`, `cable_latency_survey_data_v2.json`;
|
- `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*.
|
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. |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 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. |
|
| 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. |
|
| d3 | `src/raw/lib/d3.min.js`, embedded in the report | ISC, Mike Bostock. |
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# Cable Latency Survey
|
# Cable Latency Survey
|
||||||
|
|
||||||
Measured round-trip times between cities, compared with the minimum
|
Measurements, code and data behind four sites:
|
||||||
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
|
| Site | Contents | Source |
|
||||||
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
|
| 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/` |
|
||||||
that model.
|
| 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`).
|
- 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
|
## Files
|
||||||
|
|
||||||
@@ -18,7 +20,10 @@ that model.
|
|||||||
| `cable_latency_survey_data.json` | v1: the same measurements without coordinates. Kept for reference. |
|
| `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. |
|
| `cable_latency_survey_report.html` | The report, identical to the website. |
|
||||||
| `src/` | Pipeline code, raw logs and derived data (below). |
|
| `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
|
### Raw measurements
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
<div class="hero" id="hero">
|
<div class="hero" id="hero">
|
||||||
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
||||||
<div class="hud tl">
|
<div class="hud tl">
|
||||||
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
|
|
||||||
<h1>Cable Latency Survey</h1>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -328,7 +327,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="sec">Code and data</h2>
|
<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>
|
<div class="dl" id="code"></div>
|
||||||
<pre id="repro"></pre>
|
<pre id="repro"></pre>
|
||||||
</section>
|
</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 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 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 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 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 netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
|
||||||
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
|
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 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){
|
function nearest(mx,my){
|
||||||
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
|
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}}}
|
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)}
|
||||||
return best;
|
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;
|
let drag=null,hoverRAF=false;
|
||||||
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
|
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"],
|
["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"]];
|
["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=`
|
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>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>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>
|
<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;
|
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.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
|
||||||
document.getElementById("srcsel").value=S.src;
|
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.`}
|
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();
|
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
|
||||||
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
||||||
|
|||||||
+345
@@ -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
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -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 & 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>
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 |
@@ -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&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
@@ -222,7 +222,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
<div class="hero" id="hero">
|
<div class="hero" id="hero">
|
||||||
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
||||||
<div class="hud tl">
|
<div class="hud tl">
|
||||||
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
|
|
||||||
<h1>Cable Latency Survey</h1>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -328,7 +327,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="sec">Code and data</h2>
|
<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>
|
<div class="dl" id="code"></div>
|
||||||
<pre id="repro"></pre>
|
<pre id="repro"></pre>
|
||||||
</section>
|
</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 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 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 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 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 netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
|
||||||
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
|
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 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){
|
function nearest(mx,my){
|
||||||
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
|
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}}}
|
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)}
|
||||||
return best;
|
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;
|
let drag=null,hoverRAF=false;
|
||||||
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
|
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"],
|
["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"]];
|
["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=`
|
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>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>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>
|
<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;
|
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.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
|
||||||
document.getElementById("srcsel").value=S.src;
|
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.`}
|
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();
|
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
|
||||||
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
||||||
|
|||||||
+11
-7
@@ -214,7 +214,6 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
<div class="hero" id="hero">
|
<div class="hero" id="hero">
|
||||||
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
|
||||||
<div class="hud tl">
|
<div class="hud tl">
|
||||||
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
|
|
||||||
<h1>Cable Latency Survey</h1>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -320,7 +319,7 @@ summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;let
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="sec">Code and data</h2>
|
<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>
|
<div class="dl" id="code"></div>
|
||||||
<pre id="repro"></pre>
|
<pre id="repro"></pre>
|
||||||
</section>
|
</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 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 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 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 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 netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
|
||||||
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
|
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 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){
|
function nearest(mx,my){
|
||||||
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
|
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}}}
|
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)}
|
||||||
return best;
|
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;
|
let drag=null,hoverRAF=false;
|
||||||
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,moved:false};cv.setPointerCapture(e.pointerId)});
|
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"],
|
["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"]];
|
["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=`
|
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>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>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>
|
<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;
|
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.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
|
||||||
document.getElementById("srcsel").value=S.src;
|
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.`}
|
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();
|
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
|
||||||
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
readout();renderCard();resize();renderEx();drawSM();drawSpeed();kick();frame();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -1,5 +1,4 @@
|
|||||||
// New sections: no errors; flat panels at zero correction equal the page's straight-line counts for every source;
|
// Networks and code sections: no errors, no horizontal overflow, screenshots; the flat-fit section stays removed.
|
||||||
// the last slider stop leaves nothing inconsistent; screenshots of each section.
|
|
||||||
import { chromium } from 'playwright-core';
|
import { chromium } from 'playwright-core';
|
||||||
import { PAGE, HAND } from './paths.mjs';
|
import { PAGE, HAND } from './paths.mjs';
|
||||||
const b = await chromium.launch({ executablePath: process.env.CH });
|
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 } });
|
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()}`) });
|
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);
|
await p.goto(PAGE('site/index.html')); await p.waitForTimeout(1500);
|
||||||
for (const src of ['all', 'ours', 'gp', 'home']) {
|
if (await p.locator('h2.sec', { hasText: 'flat arrangements' }).count()) fails.push(`${w}: flat-fit section present`);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
await p.selectOption('#srcsel', 'all'); await p.waitForTimeout(200);
|
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=..');
|
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` }) }
|
await el.screenshot({ path: `out/sec_${name}_${w}.png` });
|
||||||
else await el.screenshot({ path: `out/sec_${name}_${w}.png` });
|
|
||||||
}
|
}
|
||||||
const sw = await p.evaluate(() => document.documentElement.scrollWidth);
|
const sw = await p.evaluate(() => document.documentElement.scrollWidth);
|
||||||
if (sw !== w) fails.push(`${w}: page scrollWidth ${sw}`);
|
if (sw !== w) fails.push(`${w}: page scrollWidth ${sw}`);
|
||||||
|
|||||||
@@ -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
@@ -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 trunc.mjs | tail -1 # no truncated text in the route card
|
||||||
node view_btns.mjs | tail -2 # zoom / reset buttons
|
node view_btns.mjs | tail -2 # zoom / reset buttons
|
||||||
node offline.mjs | tail -3 # site/index.html with the network blocked
|
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 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
|
[ "${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
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user