Files
cable-latency-survey/src/web/template.html
T
Toby c094eb88fa Open-source release: pipeline code, raw logs, derived data, licences, tests
Adds src/ (scripts, raw measurement logs, derived data, report source),
tests/, LICENSE (MIT, code) and LICENSE-DATA.md (CC BY 4.0 for the survey's
data; third-party terms). The report is updated to the current site; the
v1 and v2 data hand-off files are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 20:09:54 -04:00

1030 lines
93 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Cable Latency Survey</title>
<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='14' fill='%2303060b' stroke='%2346d6e6' stroke-width='2'/%3E%3Cpath d='M7 21Q15 5 25 13' fill='none' stroke='%23ffd36b' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E">
<meta name="claude:viewport-fit-sides" content="cover">
<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=Oxanium:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
/* Layout: the map is the page. Full-bleed deep-sea canvas with an instrument HUD; everything else is a short run of readouts below it. Single dark world by design. */
:root{
color-scheme:dark;
--bg:#03060b;--deep:#060c15;--land:#111a27;--coast:#22324a;--grat:#0b1420;
--ink:#e8eef6;--dim:#8794a8;--faint:#4b5869;--rule:#162131;--glass:rgba(5,10,18,.8);
--cable:#1d6f8f;--ok:#46d6e6;--fib:#ff9a3c;--bad:#ff3d5e;--photon:#ffd36b;--sel:#ffffff;
--display:"Oxanium","Eurostile","Bank Gothic",system-ui,sans-serif;
--sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{background:var(--bg)}
body{margin:0;color:var(--ink);font:15px/1.55 var(--sans)}
a{color:var(--photon)}
:focus-visible{outline:2px solid var(--photon);outline-offset:2px}
.num{font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
.eyebrow{font:500 11px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--dim)}
.ok{color:var(--ok)}.fib{color:var(--fib)}.bad{color:var(--bad)}
/* hero */
.hero{position:relative;height:clamp(600px,86vh,940px);overflow:hidden;border-bottom:1px solid var(--rule)}
#cv{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
#cv.drag{cursor:grabbing}
.hud{position:absolute;z-index:2;pointer-events:none}
.hud>*{pointer-events:auto}
.tl{left:max(20px,env(safe-area-inset-left,0px));top:20px;max-width:360px}
.tl h1{font:700 clamp(28px,3.2vw,46px)/1 var(--display);letter-spacing:.01em;margin:6px 0 10px;text-transform:uppercase;text-wrap:balance}
.tl p{margin:0;color:var(--dim);font-size:14px;max-width:36ch}
@media (min-width:901px) and (max-height:820px){.tl p{display:none}}
.side{top:20px;bottom:20px;right:max(20px,env(safe-area-inset-right,0px));width:clamp(300px,30vw,380px);display:flex;flex-direction:column;gap:10px}
.models{display:grid;grid-template-columns:1fr 1fr;gap:3px;background:var(--glass);border:1px solid var(--rule);border-radius:8px;padding:3px;backdrop-filter:blur(6px)}
.models button{font:600 12px var(--display);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);background:none;border:0;border-radius:6px;padding:8px 6px;cursor:pointer;white-space:nowrap}
.models button:hover{color:var(--ink)}
.models button.on{background:var(--ink);color:var(--bg)}
.bl{left:max(20px,env(safe-area-inset-left,0px));bottom:20px;width:min(330px,32vw)}
.readout{display:flex;align-items:flex-end;gap:14px}
.readout .big{font:700 clamp(56px,7vw,96px)/.85 var(--display);letter-spacing:-.02em}
.readout .lab{font-size:14px;color:var(--ink);max-width:22ch;line-height:1.3;padding-bottom:4px}
.readout .lab small{display:block;color:var(--dim);font-size:12.5px;margin-top:3px}
.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--dim);background:var(--glass);border:1px solid var(--rule);border-radius:8px;padding:7px 10px;backdrop-filter:blur(6px)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend i{display:inline-block;width:16px;height:3px;border-radius:2px}
.legend,.tools{flex:none}
.tools{display:flex;gap:6px;flex-wrap:wrap}
.tools button{font:500 12px var(--mono);color:var(--ink);background:var(--glass);border:1px solid var(--rule);border-radius:6px;padding:6px 10px;cursor:pointer;backdrop-filter:blur(6px)}
.tools button[aria-pressed="true"]{border-color:var(--photon);color:var(--photon)}
.tools button:disabled{opacity:.35;cursor:default}
#tip{position:absolute;z-index:4;pointer-events:none;background:var(--glass);border:1px solid var(--rule);border-radius:6px;padding:7px 9px;font-size:12.5px;line-height:1.45;max-width:260px;backdrop-filter:blur(6px)}
/* route card */
.card{flex:0 1 auto;min-height:0;display:flex;flex-direction:column;background:var(--glass);border:1px solid var(--rule);border-radius:10px;padding:14px 16px 12px;backdrop-filter:blur(8px)}
.card .body{overflow:auto;min-height:0;margin:0 -10px;padding:0 10px}
.card h2{font:600 22px/1.15 var(--display);margin:4px 0 2px;text-transform:uppercase;letter-spacing:.02em}
.card .via{font-size:12.5px;color:var(--dim);margin-bottom:10px}
.card .meas{display:flex;align-items:baseline;gap:8px;margin:6px 0 12px}
.card .meas b{font:600 34px/1 var(--display)}
.card .meas span{font-size:12.5px;color:var(--dim)}
.rows{display:grid;grid-template-columns:minmax(0,1fr);gap:11px;padding-bottom:4px}
.row .top{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}
.row .top b{font-weight:600;color:var(--ink)}
.row .top em{font-style:normal}
.row .top b{color:var(--dim)}
.row.cur .top b{color:var(--sel)}
.row .inl{display:none;color:var(--dim);font-weight:400;font-family:var(--mono)}
.row .top b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row .top em{flex:none}
.card.nokm .row .inl{display:none}
.track{position:relative;height:6px;background:#0e1724;border-radius:3px;margin-top:5px}
.track .fill{position:absolute;left:0;top:0;bottom:0;border-radius:3px}
.track .tick{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ink);border-radius:1px}
.card .foot{font-size:11.5px;color:var(--dim);margin:12px 0 0}
@media (min-width:901px) and (max-height:960px){.card{padding:10px 14px 8px}.tabs{margin-bottom:4px}.card h2{font-size:19px;margin:0 0 2px}.card .via{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px}.card .meas{margin:0 0 5px}.card .meas b{font-size:28px}.rows{gap:6px}.row .top{font-size:12px}.row .inl{display:inline}.row .sub{display:none}.track{margin-top:3px}.card .rows+.foot{display:none}.legend{padding:5px 9px;gap:3px 12px}}
.seg{display:flex;background:var(--glass);border:1px solid var(--rule);border-radius:7px;padding:2px;backdrop-filter:blur(6px)}
.seg button{font:500 12px var(--mono);color:var(--dim);background:none;border:0;border-radius:5px;padding:5px 10px;cursor:pointer}
.seg button.on{background:var(--photon);color:var(--bg)}
.tabs{display:flex;gap:2px;margin:-4px -4px 12px;border-bottom:1px solid var(--rule)}
.tabs button{font:600 11.5px var(--display);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);background:none;border:0;border-bottom:2px solid transparent;padding:6px 8px 8px;cursor:pointer}
.tabs button.on{color:var(--ink);border-bottom-color:var(--photon)}
.list{margin:0 -6px}
.li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1px 10px;padding:7px 6px;border-radius:6px;cursor:pointer;font-size:13px}
.li:hover,.li.on{background:rgba(255,255,255,.06)}
.li.on b{color:var(--sel)}
.li b{font-weight:600}
.li span{color:var(--dim);font-size:11.5px;white-space:normal}
.li em{font-style:normal;text-align:right}
button#rbig{background:none;border:0;padding:0;cursor:pointer}
.readout .lab u{color:var(--photon);text-decoration:none;cursor:pointer}
.srcw{margin-top:14px;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font:500 11px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
.srcw select{font:500 13px var(--sans);letter-spacing:0;text-transform:none;color:var(--ink);background:var(--glass);border:1px solid var(--rule);border-radius:6px;padding:6px 8px;min-width:220px;cursor:pointer}
.ex{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
@media (max-width:1100px){.ex{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:520px){.ex{grid-template-columns:1fr}}
.ex h3{font:600 14px var(--display);text-transform:uppercase;letter-spacing:.06em;margin:0 0 6px}
.ex canvas{width:100%;aspect-ratio:1/1;display:block;border:1px solid var(--rule);border-radius:8px}
.ex p{font-size:13px;color:var(--dim);margin:8px 0 0}
.ex p b{color:var(--ink);font-weight:600}
.ff{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
@media (max-width:900px){.ff{grid-template-columns:1fr}}
.ff h3{font:600 14px var(--display);text-transform:uppercase;letter-spacing:.06em;margin:0 0 6px}
.ff canvas{width:100%;aspect-ratio:1/1;display:block;border:1px solid var(--rule);border-radius:8px}
.ff p{font-size:13px;color:var(--dim);margin:8px 0 0}
.ff p b{color:var(--ink);font-weight:600}
#fftab,#netx{min-width:640px}#netanc{min-width:420px}
.ffctl{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin:0 0 14px}
.ffctl label{font:500 11px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
.ffctl input{flex:1 1 220px;max-width:420px;accent-color:var(--photon)}
.ffctl output{font:500 13px var(--mono);color:var(--ink);min-width:16ch}
h3.sub{font:600 14px var(--display);text-transform:uppercase;letter-spacing:.06em;margin:0 0 8px}
.ffkey{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12.5px;color:var(--dim);margin:10px 0 0}
.ffkey i{display:inline-block;width:14px;height:2px;vertical-align:middle;margin-right:6px}
.ffkey i.o{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--dim);background:none}
.ffkey i.f{width:8px;height:8px;border-radius:50%;background:var(--sel)}
main a{color:var(--photon);text-decoration:none;border-bottom:1px solid rgba(255,211,107,.35)}
main a:hover,main a:focus-visible{border-bottom-color:var(--photon)}
/* sections */
main{max-width:1180px;margin:0 auto;padding-block:8px 64px;padding-left:max(20px,env(safe-area-inset-left,0px));padding-right:max(20px,env(safe-area-inset-right,0px))}
section{padding-top:56px}
h2.sec{font:700 26px/1.1 var(--display);text-transform:uppercase;letter-spacing:.03em;margin:6px 0 8px;text-wrap:balance}
.lede{color:var(--dim);max-width:68ch;margin:0 0 18px}
.steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
.steps div{background:var(--bg);padding:16px 18px}
.steps b{display:block;font:600 15px var(--display);text-transform:uppercase;letter-spacing:.06em;margin:6px 0 4px}
.steps p{margin:0;color:var(--dim);font-size:14px}
@media (max-width:760px){.steps{grid-template-columns:1fr}}
.tw{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:14px}
th,td{padding:10px 12px;text-align:left;border-top:1px solid var(--rule);vertical-align:middle}
th{font:500 11px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--dim);border-top:0}
.score td.n{font:600 26px/1 var(--display)}
.score td.n small{font:400 12px var(--mono);color:var(--faint);margin-left:4px}
.score td.n small.br{display:block;margin:5px 0 0;font-size:11px}
.score td.m{font:600 15px var(--display);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}
.note{color:var(--dim);font-size:13px;max-width:90ch;margin:10px 0 0}
.sm{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
@media (max-width:1100px){.sm{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:520px){.sm{grid-template-columns:1fr}}
.fig{position:relative}
.fig h3{font:600 14px var(--display);text-transform:uppercase;letter-spacing:.06em;margin:0}
.fig .fc{font-size:12.5px;color:var(--dim);margin:2px 0 6px}
.fig canvas{width:100%;aspect-ratio:1/1;display:block;border:1px solid var(--rule);border-radius:8px}
.stip{position:absolute;pointer-events:none;background:var(--glass);border:1px solid var(--rule);border-radius:6px;padding:6px 8px;font-size:12.5px;line-height:1.4;z-index:5;max-width:230px}
.x{display:grid}
.xr{display:grid;grid-template-columns:minmax(180px,260px) minmax(0,1fr);gap:8px 18px;align-items:center;padding:14px 0;border-top:1px solid var(--rule);cursor:pointer}
.xr:hover .xn b{color:var(--photon)}
.xn b{display:block;font:600 15px var(--display);text-transform:uppercase;letter-spacing:.04em}
.xn span{font-size:12.5px;color:var(--dim)}
.xb{position:relative;height:56px}
.xb .bar{position:absolute;left:0;height:7px;border-radius:4px}
.xb .lb{position:absolute;left:0;font:500 11.5px/1 var(--mono);white-space:nowrap}
.xb .tk{position:absolute;top:10px;bottom:-2px;width:2px;background:var(--ink)}
@media (max-width:640px){.xr{grid-template-columns:1fr}}
.ladder{display:grid;gap:10px;margin:6px 0 0}
.lad .t{display:flex;justify-content:space-between;gap:12px;font-size:13px;margin-bottom:4px}
.lad .t b{font-weight:600}
.lad .bar{height:8px;border-radius:4px}
.key{display:grid;gap:10px;font-size:13.5px}
.key div{display:grid;grid-template-columns:28px minmax(0,1fr);gap:10px;align-items:start}
.key i{display:block;height:3px;border-radius:2px;margin-top:9px}
.key i.dot{width:9px;height:9px;border-radius:50%;margin:5px 0 0 9px;box-shadow:0 0 8px var(--photon)}
.key i.ring{width:14px;height:14px;border-radius:50%;border:2px solid var(--bad);background:none;margin:3px 0 0 6px}
#speedcv{width:100%;height:330px;display:block;border:1px solid var(--rule);border-radius:8px}
.two{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,1fr);gap:28px;align-items:start}
.two>div{min-width:0}
@media (max-width:900px){.two{grid-template-columns:1fr}}
select,input,button.go{font:inherit;font-size:14px;padding:7px 10px;border-radius:6px;border:1px solid var(--rule);background:#0a111c;color:var(--ink)}
.byo{display:flex;flex-wrap:wrap;gap:12px 14px;align-items:flex-end}
.byo label{display:flex;flex-direction:column;gap:5px;font:500 11px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--dim);flex:1 1 210px}
.byo label input,.byo label select{width:100%;font-family:var(--sans);letter-spacing:0;text-transform:none}
button.go{background:var(--photon);border-color:var(--photon);color:var(--bg);font:600 13px var(--display);letter-spacing:.1em;text-transform:uppercase;padding:9px 18px;cursor:pointer}
pre{font:12.5px/1.6 var(--mono);background:#070d16;border:1px solid var(--rule);border-radius:8px;padding:12px 14px;overflow:auto;margin:14px 0}
.verdict{font-size:15px;margin:14px 0 8px}
details{border-top:1px solid var(--rule);padding:14px 0}
details:last-child{border-bottom:1px solid var(--rule)}
summary{cursor:pointer;font:600 14px var(--display);text-transform:uppercase;letter-spacing:.06em}
.dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 18px;font-size:13.5px;margin-top:12px}
.dl>div:nth-child(odd){color:var(--dim)}
@media (max-width:640px){.dl{grid-template-columns:1fr}.dl>div:nth-child(odd){margin-top:6px}}
.mini{font-size:12.5px}.mini td,.mini th{padding:5px 8px}
.gwrap{max-height:460px;overflow:auto;margin-top:10px}
@media (max-width:900px){
.hero{height:auto;overflow:visible;display:flex;flex-direction:column}
#cv{position:relative;order:3;height:min(100vw,66vh);min-height:340px}
.hud{position:relative;left:auto;right:auto;top:auto;bottom:auto}
.side{display:contents}
.tl{order:1;padding:20px max(20px,env(safe-area-inset-left,0px)) 10px;max-width:none}
.models{order:2;margin:0 max(20px,env(safe-area-inset-left,0px)) 12px;min-width:0}
.bl{order:4;width:auto;padding:14px max(20px,env(safe-area-inset-left,0px)) 0}
.readout .big{font-size:56px}
.tools{order:5;margin:12px max(20px,env(safe-area-inset-left,0px)) 0}
.legend{order:6;margin:10px max(20px,env(safe-area-inset-left,0px)) 0}
.card{order:7;margin:14px max(20px,env(safe-area-inset-left,0px)) 20px}
.card .body{overflow:visible}
#tip{position:absolute}
.score th,.score td{padding:7px 4px}
.score td.n{font-size:19px}
.score td.n small{display:block;margin:3px 0 0}
.score td.m{font-size:12.5px;white-space:normal}
.score th{font-size:9.5px;letter-spacing:.03em}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
<div class="hero" id="hero">
<canvas id="cv" aria-label="Map of measured routes along submarine cables"></canvas>
<div class="hud tl">
<div class="eyebrow" id="eyeb">16 cities · 608 cables · 4 maps</div>
<h1>Cable Latency Survey</h1>
<p>Measured round-trip times between cities, compared with the minimum propagation time along submarine-cable routes on four models of the Earth's surface.</p>
</div>
<div class="hud bl"><div class="readout"><button class="big num" id="rbig" aria-label="List these routes">0</button><div class="lab" id="rlab"></div></div>
<label class="srcw" for="srcsel">Data source<select id="srcsel"><option value="all">All live sources</option><option value="ours">Vultr servers</option><option value="gp">Globalping probes</option><option value="home">Residential connection, Michigan</option></select></label></div>
<div class="hud side" id="side">
<div class="models" role="group" aria-label="Map of the Earth"><button data-v="globe">Globe</button><button data-v="ae">North-pole AE</button><button data-v="dp">Dual-pole AE</button><button data-v="merc">Mercator</button></div>
<div class="card" id="card"></div>
<div class="legend" id="legend"></div>
<div class="tools"><div class="seg" id="mode" role="group" aria-label="Route type"><button data-mode="cable">Cable routes</button><button data-mode="line">Straight lines</button></div><button id="tbad" aria-pressed="false">Inconsistent only</button><button id="tpulse" aria-pressed="true">Animation</button><button id="tzoom">Zoom to route</button><button id="treset" disabled>Reset view</button><button id="tin" aria-label="Zoom in">+</button><button id="tout" aria-label="Zoom out">−</button></div>
</div>
<div id="tip" hidden></div>
</div>
<main>
<section>
<div class="steps">
<div><span class="eyebrow">01</span><b>Measurement</b><p id="step1"></p></div>
<div><span class="eyebrow">02</span><b>Routing</b><p>Each pair is routed over in-service submarine cables and straight overland links, separately on each model.</p></div>
<div><span class="eyebrow">03</span><b>Comparison</b><p>A round-trip time below the fibre propagation floor along a model's route is inconsistent with that model.</p></div>
</div>
</section>
<section>
<h2 class="sec">Results by model</h2>
<div class="tw"><table class="score" id="score"></table></div>
<p class="note" id="scorenote"></p>
</section>
<section>
<h2 class="sec">Propagation limits</h2>
<div class="two">
<div>
<p class="lede">Light travels at 299,792 km/s in vacuum and at 204,190 km/s in standard single-mode fibre (group index 1.468). Submarine cables are fibre, so the minimum round-trip time over a route is 2 × route length ÷ 204,190 km/s. This floor determines each bar and the speed of the animated markers on the map.</p>
<p class="lede">Measured times exceed the floor because installed cable is longer than its charted route and because routers and queues add delay. A time above the floor is therefore uninformative. A time below it implies that the model places the endpoints too far apart.</p>
<p class="lede">The vacuum speed exceeds the fibre speed by 47%, and radio propagation through air is within 0.03% of the vacuum speed. A time below the vacuum floor cannot be achieved by cable, radio or satellite.</p>
<div class="ladder" id="ladder"></div>
</div>
<div>
<div class="eyebrow" style="margin-bottom:10px">Key</div>
<div class="key">
<div><i style="background:var(--ok)"></i><span><b class="ok">Cyan</b>: the measured time is at or above the fibre floor; consistent with the model.</span></div>
<div><i style="background:var(--fib)"></i><span><b class="fib">Orange</b>: below the fibre floor but above the vacuum floor; inconsistent with the model, since submarine cables are fibre.</span></div>
<div><i style="background:var(--bad)"></i><span><b class="bad">Red</b>: below the vacuum floor; inconsistent with the model for any transmission medium.</span></div>
<div><i style="background:var(--sel);height:4px"></i><span><b style="color:var(--sel)">White</b>: the selected route.</span></div>
<div><i class="dot" style="background:var(--photon)"></i><span><b style="color:var(--photon)">Gold marker</b>: light in fibre traversing the route and returning at 204,190 km/s, slowed 24×.</span></div>
<div><i class="ring"></i><span><b>Ring at the origin</b>: arrival of the measured reply, in the route's colour. A ring that appears before the marker returns indicates a reply faster than light along that route.</span></div>
</div>
</div>
</div>
<div class="fig" style="margin-top:22px"><h3>Implied propagation speed</h3><div class="fc" id="speedsub"></div><canvas id="speedcv" aria-label="Implied propagation speed of each route on each model"></canvas><div class="stip" id="spt" hidden></div></div>
<p class="note" id="speednote"></p>
</section>
<section>
<h2 class="sec">Distance on each model</h2>
<p class="lede" id="exlede"></p>
<div class="ex" id="ex"></div>
</section>
<section>
<h2 class="sec">All measurements</h2>
<p class="lede" id="smlede"></p>
<div class="sm" id="sm"></div>
<p class="note" id="smnote"></p>
</section>
<section>
<h2 class="sec">Cable crossings</h2>
<p class="lede">RIPE Atlas anchor-to-anchor traceroutes. The increase in round-trip time at the hop that spans the sea isolates the cable segment; it is compared with the fibre floor along that cable's charted route.</p>
<div class="x" id="xs"></div>
<p class="note">Bars: round-trip fibre floor along the cable's charted route. White mark: measured increase, minimum over 24 hours of traceroutes at 15-minute intervals. Select a crossing to show its route on the map.</p>
</section>
<section>
<h2 class="sec">Networks on the measured paths</h2>
<p class="lede">Every endpoint and traceroute hop was attributed to the autonomous system (AS) that announced its address in the public BGP routing table on 23 October 2026 (RouteViews route-views2, via CAIDA prefix2as), named from the regional internet registries' AS list (RIPE NCC). Addresses absent from the table were matched to internet exchange point prefixes registered in PeeringDB. Each hop is a router of an identified network, and adjacent networks exchange traffic only under peering or transit agreements configured by both parties. The attribution can be repeated from the cited public sources.</p>
<p class="note" id="netsum" style="margin:0 0 18px"></p>
<div class="two">
<div><h3 class="sub">Measurement endpoints</h3><div class="dl" id="netend" style="margin-top:6px"></div>
<h3 class="sub" style="margin-top:22px">RIPE Atlas anchors</h3><div class="gwrap" style="max-height:340px"><table class="mini" id="netanc"></table></div></div>
<div><h3 class="sub">Carriers on the Vultr traceroutes</h3><div class="gwrap" style="max-height:300px"><table class="mini" id="nettr"></table></div>
<h3 class="sub" style="margin-top:22px">Internet exchange points</h3><div class="tw"><table class="mini" id="netix"></table></div></div>
</div>
<h3 class="sub" style="margin-top:26px">Sea crossings: networks either side of the jump</h3>
<div class="tw"><table class="mini" id="netx"></table></div>
<p class="note" id="netnote"></p>
</section>
<section>
<h2 class="sec">Best attainable flat arrangements</h2>
<p class="lede">Latency bounds distances only from above, so a free flat arrangement can satisfy any set of measurements by shrinking. The informative question is how far a given flat map would have to be corrected. Each city may move at most the stated distance from where the map places it; the arrangement shown minimises the total excess over all measured pairs at the speed of light in fibre, using straight lines (the shortest possible cable). The final setting is the smallest correction at which no route remains inconsistent. The problem is convex, so this minimum is global.</p>
<div class="ffctl"><label for="ffb">Maximum correction per city</label><input id="ffb" type="range" min="0" max="6" step="1" value="0"><output id="ffbv" for="ffb"></output></div>
<div class="ff" id="ff"></div>
<div class="ffkey"><span><i class="o"></i>position on the map</span><span><i class="f"></i>corrected position</span><span><i style="background:var(--dim)"></i>correction</span><span><i style="background:var(--fib)"></i>faster than light in fibre</span><span><i style="background:var(--bad)"></i>faster than light in vacuum</span></div>
<div class="tw" style="margin-top:16px"><table class="mini" id="fftab"></table></div>
<p class="note" id="ffnote"></p>
</section>
<section>
<h2 class="sec">Independent verification</h2>
<p class="lede">To reproduce a measurement, ping one of the anchors from any computer and enter the minimum round-trip time. Local network and access delays can only increase the measured value.</p>
<form id="byo" class="byo" autocomplete="off">
<label for="byoA">Location<input id="byoA" list="byocities" placeholder="City, or lat, lon"></label>
<label for="byoB">Anchor<select id="byoB"></select></label>
<label for="byoT">Minimum RTT, ms<input id="byoT" type="number" min="0" step="0.1"></label>
<button class="go" type="submit">Evaluate</button>
</form>
<datalist id="byocities"></datalist>
<pre id="byocmd"></pre>
<div id="byoout"></div>
</section>
<section>
<h2 class="sec">Code and data</h2>
<p class="lede">The code, the raw measurement logs and the derived data are public. Code is released under the MIT licence; the measurements and derived data under CC BY 4.0. Third-party inputs remain under their own terms and are downloaded by a script rather than redistributed.</p>
<div class="dl" id="code"></div>
<pre id="repro"></pre>
</section>
<section>
<h2 class="sec">Method and data</h2>
<details><summary>Route construction</summary><div class="dl" id="m1"></div></details>
<details><summary>Data sources</summary><div class="dl" id="m2"></div></details>
<details><summary>Sensitivity</summary><div class="dl" id="m3"></div></details>
<details><summary>All live measurements</summary><div class="gwrap"><table class="mini" id="grid"></table></div></details>
</section>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
const D=/*__DATA__*/null;
const R=6371,CV=299792.458,NF=1.4682,CF=CV/NF,RAD=Math.PI/180;
const MODELS=[["globe","Globe"],["ae","North-pole AE"],["dp","Dual-pole AE"],["merc","Mercator"]];
const MNAME=Object.fromEntries(MODELS),NK={globe:"globe",ae:"ae",dp:"dualpole",merc:"mercator"};
const reduce=matchMedia("(prefers-reduced-motion: reduce)").matches;
const S={src:"all",view:"ae",sel:null,hover:null,site:null,onlyBad:false,pulses:!reduce,mode:"cable",tab:"route"};
const MODE={cable:"sea-cable route",line:"straight line"};
const GIT="https://git.aethercosmology.com/toby/cable-latency-survey",SRCV=GIT+"/src/branch/main/",RAWV=GIT+"/raw/branch/main/";
const lnk=(href,t)=>`<a href="${href}" target="_blank" rel="noopener">${t}</a>`;
const netName=n=>/^home/.test(n)?"residential connection, Michigan":n.replace(/^our /,"");
const fmt=(x,d=1)=>x.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d});
const fint=x=>Math.round(x).toLocaleString("en-US");
const floorMs=(km,v=CF)=>2*km/v*1000;
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const C={bg:"#03060b",deep:"#060c15",land:"#111a27",coast:"#22324a",grat:"#0b1420",ink:"#e8eef6",dim:"#8794a8",faint:"#4b5869",rule:"#162131",cable:"#1d6f8f",ok:"#46d6e6",bad:"#ff4a68",photon:"#ffd36b",fib:"#ff9a3c",sel:"#ffffff"};
const TC=()=>[C.ok,C.fib,C.bad],TCLS=["ok","fib","bad"],TTXT=["consistent","faster than light in fibre","faster than light in vacuum"];
const FONT=()=>getComputedStyle(document.body).fontFamily;
/* ---------- geometry (same maths as models.py) ---------- */
function gc(a,b){const p1=a.lat*RAD,p2=b.lat*RAD,dl=(b.lon-a.lon)*RAD;const h=Math.sin((p2-p1)/2)**2+Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;return 2*R*Math.asin(Math.min(1,Math.sqrt(h)))}
function aeXY(s){const r=R*(90-s.lat)*RAD,t=s.lon*RAD;return[r*Math.sin(t),-r*Math.cos(t)]}
function dpXY(s){const p=s.lat*RAD,l=s.lon*RAD;const c=Math.acos(Math.max(-1,Math.min(1,Math.cos(p)*Math.cos(l))));const k=c<1e-9?1:c/Math.sin(c);return[R*k*Math.cos(p)*Math.sin(l),R*k*Math.sin(p)]}
function mercXY(s){const la=Math.max(-85,Math.min(85,s.lat));return[R*(((s.lon-180)%360+360)%360)*RAD,R*Math.log(Math.tan(Math.PI/4+la*RAD/2))]}
const XY={ae:aeXY,dp:dpXY,merc:mercXY};
function dist(m,a,b){if(m==="globe")return gc(a,b);const A=XY[m](a),B=XY[m](b);return Math.hypot(A[0]-B[0],A[1]-B[1])}
/* ---------- live grid along cables ---------- */
const LS=D.live.sites.map(([name,anchor,lat,lon,ip])=>({name,anchor,lat,lon,ip}));
/* live routes, one per city pair, built from the cells a data source keeps */
const SRC={all:{name:"All live sources",label:"all live sources",keep:()=>true},ours:{name:"Vultr servers",label:"the Vultr servers",keep:c=>/Vultr/.test(c[5])},
gp:{name:"Globalping probes",label:"Globalping probes",keep:c=>!/Vultr|^home/.test(c[5])},home:{name:"Residential connection",label:"the residential connection",keep:c=>/^home/.test(c[5])}};
function mkLP(keep){
const m=new Map();
for(const cell of D.live.cells){if(!keep(cell))continue;const [a,b,rtt,loc,run,net,ref]=cell,lo=Math.min(a,b),hi=Math.max(a,b),k=lo+"-"+hi;
if(!m.has(k))m.set(k,{a:lo,b:hi,cells:[]});m.get(k).cells.push({src:a,dst:b,rtt,loc,run,net,ref})}
return[...m.values()].map((p,idx)=>{
p.cells.sort((x,y)=>(x.rtt+x.loc)-(y.rtt+y.loc));const c=p.cells[0];
Object.assign(p,{idx,rtt:c.rtt,allow:c.loc,T:c.rtt+c.loc,from:c.src,net:D.network.live[p.a+"-"+p.b]});
const A=LS[c.src],B=LS[c.src===p.a?p.b:p.a];
const mk=(m,km,path,kinds,via,land)=>{const fl=floorMs(Math.max(0,km-25)),cum=[0];
for(let i=1;i<path.length;i++)cum.push(cum[i-1]+dist(m,{lon:path[i-1][0],lat:path[i-1][1]},{lon:path[i][0],lat:path[i][1]}));
const vfl=floorMs(Math.max(0,km-25),CV);
return{km,fl,vfl,bad:p.T<fl,vac:p.T<vfl,tier:p.T<vfl?2:p.T<fl?1:0,path,kinds,via,land,cum}};
p.r={cable:{},line:{}};
for(const [m] of MODELS){const n=p.net[NK[m]];let path=n.path,kinds=n.kinds;
if(p.from!==p.a){path=path.slice().reverse();kinds=kinds.split("").reverse().join("")}
p.r.cable[m]=mk(m,n.km,path,kinds,n.via,n.land);
p.r.line[m]=mk(m,dist(m,A,B),[[A.lon,A.lat],[B.lon,B.lat]],"s",[],0)}
return p;
});
}
const LPS=Object.fromEntries(Object.entries(SRC).map(([k,v])=>[k,mkLP(v.keep)]));
let LP=LPS.all;
const rt=(p,m=S.view,mode=S.mode)=>p.r[mode][m];
const liveBad=(m,mode=S.mode)=>LP.filter(p=>rt(p,m,mode).bad).length;
const liveVac=(m,mode=S.mode)=>LP.filter(p=>rt(p,m,mode).vac).length;
/* ---------- WonderNetwork along cables ---------- */
const WS=D.wn.sites.map(([name,cc,lat,lon])=>({name,cc,lat,lon}));
const WN=(()=>{const n=D.wn.pairs.length/3,gap=new Set(D.network.wn_gap),o={a:[],b:[],T:[],km:{},bad:{},keep:[]};
for(let i=0;i<n;i++){o.a.push(D.wn.pairs[3*i]);o.b.push(D.wn.pairs[3*i+1]);o.T.push(D.wn.pairs[3*i+2]+1);o.keep.push(!gap.has(i))}
o.km={cable:{},line:{}};o.bad={cable:{},line:{}};o.vac={cable:{},line:{}};
for(const [m] of MODELS){o.km.cable[m]=D.network.wn_km[NK[m]];o.km.line[m]=o.a.map((a,i)=>dist(m,WS[a],WS[o.b[i]]));
for(const md of["cable","line"]){const ok=i=>md==="line"||o.keep[i]; // a cable-route gap only voids the cable-route test
o.bad[md][m]=o.km[md][m].map((k,i)=>ok(i)&&o.T[i]<floorMs(Math.max(0,k-200)));
o.vac[md][m]=o.km[md][m].map((k,i)=>ok(i)&&o.T[i]<floorMs(Math.max(0,k-200),CV))}}
o.n=o.keep.filter(Boolean).length;o.nAll=n;return o})();
const wnBad=(m,mode=S.mode)=>WN.bad[mode][m].filter(Boolean).length;
const wnVac=(m,mode=S.mode)=>WN.vac[mode][m].filter(Boolean).length;
/* ---------- cables ---------- */
const CAB=D.cables.cables,CIDX=Object.fromEntries(CAB.map((c,i)=>[c.id,i]));
const cabBad=(c,m)=>c.span[NK[m]]>c.length*1.02+50;
const NET={type:"MultiLineString",coordinates:Object.values(D.netlines).flat()};
const XS=D.trace.filter(t=>t.cable&&CIDX[t.cable]!==undefined).map(t=>({...t,c:CAB[CIDX[t.cable]]}));
const xBad=m=>XS.filter(t=>t.jump<floorMs(t.c.route[NK[m]])).length;
const xVac=m=>XS.filter(t=>t.jump<floorMs(t.c.route[NK[m]],CV)).length;
/* ---------- map ---------- */
const hero=document.getElementById("hero"),cv=document.getElementById("cv"),ctx=cv.getContext("2d"),tip=document.getElementById("tip");
const bg=document.createElement("canvas"),bgx=bg.getContext("2d");
const ROT0=[-160,25];
let Wd=0,Ht=0,DPR=1,bgKey="",globeRot=ROT0.slice(),anim=null,tween=null;
const FIT={}; // each view's default fit, cached
const T={};
const grat=d3.geoGraticule10();
const landGeo={type:"MultiPolygon",coordinates:D.land};
const landMorph={type:"MultiPolygon",coordinates:D.land.map(poly=>poly.map(ring=>{
if(ring[0][0]!==-180||Math.min(...ring.map(p=>p[1]))>-80)return ring;
const out=[...ring.slice(0,-1),[179.999,ring[0][1]]];for(let L=179.999;L>-180;L-=2)out.push([L,-89.9]);
out.push([-179.999,-89.9],[-179.999,ring[0][1]],ring[0]);return out}))};
function baseProj(v){
if(v==="globe")return d3.geoOrthographic().rotate(globeRot);
if(v==="ae")return d3.geoAzimuthalEquidistant().rotate([0,-90]);
if(v==="dp")return d3.geoAzimuthalEquidistant();
return d3.geoMercator();
}
const wide=()=>innerWidth>900;
/* largest map that fits left of the side column without touching the title or the counter */
function fitT(v){
const K=r=>v==="globe"?r:r/Math.PI;
if(!wide()){const r=Math.min(Wd,Ht)/2-10;return{k:K(r),x:Wd/2,y:Ht/2}}
const cr=cv.getBoundingClientRect(),pad=10,rel=el=>{const r=el.getBoundingClientRect();return[r.left-cr.left-pad,r.top-cr.top-pad,r.right-cr.left+pad,r.bottom-cr.top+pad]};
const avoid=[rel(document.querySelector(".tl")),rel(document.querySelector(".bl"))],x0=12,x1=rel(document.getElementById("side"))[0],y0=12,y1=Ht-12,sq=v==="merc";
const clash=(cx,cy,r)=>avoid.some(b=>{if(sq)return cx-r<b[2]&&cx+r>b[0]&&cy-r<b[3]&&cy+r>b[1];const dx=Math.max(b[0]-cx,0,cx-b[2]),dy=Math.max(b[1]-cy,0,cy-b[3]);return dx*dx+dy*dy<r*r});
const R0=Math.min(x1-x0,y1-y0)/2,mx=(x0+x1)/2,my=(y0+y1)/2;
for(let f=1;f>=.4;f-=.02){const r=R0*f;let best=null,bd=1e9;
for(let cx=x0+r;cx<=x1-r+.1;cx+=6)for(let cy=y0+r;cy<=y1-r+.1;cy+=6){if(clash(cx,cy,r))continue;const d=Math.hypot(cx-mx,cy-my);if(d<bd){bd=d;best=[cx,cy]}}
if(best)return{k:K(r),x:best[0],y:best[1]}}
return{k:K(R0*.4),x:mx,y:my};
}
function projFor(v){const t=T[v];return baseProj(v).scale(t.k).translate([t.x,t.y])}
function mixProj(a,b,t){
const pa=baseProj(a).scale(1).translate([0,0]),pb=baseProj(b).scale(1).translate([0,0]),cl=p=>[p[0],Math.max(-85,Math.min(85,p[1]))];
const raw=(l,p)=>{const q=[l/RAD,p/RAD],A=a==="merc"?pa(cl(q)):pa(q),B=b==="merc"?pb(cl(q)):pb(q);return[A[0]+(B[0]-A[0])*t,-(A[1]+(B[1]-A[1])*t)]};
const ta=T[a],tb=T[b];
return d3.geoProjection(raw).scale(ta.k+(tb.k-ta.k)*t).translate([ta.x+(tb.x-ta.x)*t,ta.y+(tb.y-ta.y)*t]).precision(.4);
}
function resize(){
DPR=Math.min(2,window.devicePixelRatio||1);const r=cv.getBoundingClientRect();Wd=r.width;Ht=r.height;
for(const c of[cv,bg]){c.width=Math.round(Wd*DPR);c.height=Math.round(Ht*DPR)}
for(const [m] of MODELS){FIT[m]=fitT(m);T[m]={...FIT[m]}}bgKey="";
}
const center=()=>[-globeRot[0],-globeRot[1]];
const visible=(lon,lat)=>S.view!=="globe"||d3.geoDistance([lon,lat],center())<Math.PI/2;
function shown(){return LP.filter(p=>(!S.onlyBad||rt(p).bad||p.idx===S.sel)&&(S.site===null||p.a===S.site||p.b===S.site))}
let hits=[],siteHits=[];
/* want: "x" = sea-cable legs (or the straight line), "l" = overland legs */
function routeLine(g,proj,view,p,morph,want){
const r=rt(p,view),pts=r.path,kinds=r.kinds,segs=[],c=center(),glob=view==="globe"&&!morph;let cur=[];
const flush=()=>{if(cur.length>1)segs.push(cur);cur=[]};
for(let i=1;i<pts.length;i++){
if((want==="l")!==(kinds[i-1]==="l")){flush();continue}
if(glob){const n=Math.max(1,Math.ceil(d3.geoDistance(pts[i-1],pts[i])/RAD/3)),ip=d3.geoInterpolate(pts[i-1],pts[i]);
for(let k=0;k<=n;k++){const q=ip(k/n);if(d3.geoDistance(q,c)<Math.PI/2-.01){if(k===0&&cur.length)continue;cur.push(proj(q))}else flush()}}
else{if(!cur.length)cur.push(proj(pts[i-1]));cur.push(proj(pts[i]))}
}
flush();
for(const sg of segs){g.moveTo(sg[0][0],sg[0][1]);for(let j=1;j<sg.length;j++)g.lineTo(sg[j][0],sg[j][1])}
return segs;
}
function paint(g,proj,view,morph,alpha=1){
const path=d3.geoPath(proj,g),t=T[view];
g.save();g.globalAlpha=alpha;
if(!morph){
g.beginPath();
if(view==="merc")g.rect(t.x-Math.PI*t.k,t.y-Math.PI*t.k,2*Math.PI*t.k,2*Math.PI*t.k);
else g.arc(t.x,t.y,view==="globe"?t.k:Math.PI*t.k,0,2*Math.PI);
const rg=g.createRadialGradient(t.x,t.y,0,t.x,t.y,(view==="globe"?1:Math.PI)*t.k*1.05);
rg.addColorStop(0,"#0a1626");rg.addColorStop(1,C.deep);g.fillStyle=rg;g.fill();
g.strokeStyle="#1a2a40";g.lineWidth=1;g.stroke();
}
g.beginPath();path(grat);g.strokeStyle=C.grat;g.lineWidth=1;g.stroke();
g.beginPath();path(morph?landMorph:landGeo);g.fillStyle=C.land;g.fill();g.strokeStyle=C.coast;g.lineWidth=.6;g.stroke();
g.beginPath();path(NET);g.strokeStyle=C.cable;g.globalAlpha=alpha*.55;g.lineWidth=.7;g.stroke();g.globalAlpha=alpha;
const list=shown();hits=[];
const stroke=(w,dash)=>{g.setLineDash(dash?[5,5]:[]);g.lineWidth=w;g.stroke();g.setLineDash([])};
for(const tier of[0,1,2]){
g.strokeStyle=TC()[tier];g.globalAlpha=alpha*[.42,.9,.95][tier];
for(const want of["x","l"]){g.beginPath();
for(const p of list){if(rt(p,view).tier!==tier)continue;for(const sg of routeLine(g,proj,view,p,morph,want))hits.push([p.idx,sg])}
stroke([1.1,1.7,1.9][tier],want==="l")}
}
for(const [i,col,w] of [[S.hover,"rgba(255,255,255,.55)",2],[S.sel,C.sel,2.8]]){
if(i===null)continue;g.globalAlpha=alpha;g.strokeStyle=col;g.shadowColor=col;g.shadowBlur=i===S.sel?10:0;
for(const want of["x","l"]){g.beginPath();routeLine(g,proj,view,LP[i],morph,want);stroke(w,want==="l")}
g.shadowBlur=0;
}
siteHits=[];g.globalAlpha=alpha;g.font=`500 12px ${FONT()}`;
const sp=S.sel!==null?LP[S.sel]:null,boxes=[],labels=[];
const used=new Set(LP.flatMap(p=>[p.a,p.b]));
LS.forEach((s,i)=>{if(!used.has(i))return;if(!morph&&!visible(s.lon,s.lat))return;const q=proj([s.lon,s.lat]);if(!q||!isFinite(q[0]))return;
const hot=sp&&(i===sp.a||i===sp.b)||i===S.site;
g.beginPath();g.arc(q[0],q[1],hot?4.5:3,0,2*Math.PI);g.fillStyle=hot?C.sel:"#aab6c6";g.fill();g.lineWidth=2;g.strokeStyle=C.deep;g.stroke();
siteHits.push([i,q]);labels.push({s,q,hot})});
labels.sort((a,b)=>b.hot-a.hot);g.lineWidth=3;g.strokeStyle=C.deep;
for(const {s,q,hot} of labels){const w=g.measureText(s.name).width;
for(const [dx,dy,al] of [[7,-6,"left"],[7,15,"left"],[-7,-6,"right"],[-7,15,"right"]]){
const x0=al==="left"?q[0]+dx:q[0]+dx-w,y0=q[1]+dy-11;if(boxes.some(b=>x0<b[2]&&x0+w>b[0]&&y0<b[3]&&y0+12>b[1]))continue;
boxes.push([x0-2,y0-1,x0+w+2,y0+13]);g.textAlign=al;g.fillStyle=hot?C.ink:C.dim;g.strokeText(s.name,q[0]+dx,q[1]+dy);g.fillText(s.name,q[0]+dx,q[1]+dy);break}}
g.textAlign="left";
if(!morph){
const pk=t.k/R;let km=1000;for(const n of[1000,2000,5000,10000])if(n*pk<150)km=n;
const w=km*pk,x=wide()?Math.max(16,t.x-w/2):16,y=Ht-18;
g.strokeStyle=C.dim;g.lineWidth=1;g.beginPath();g.moveTo(x,y-4);g.lineTo(x,y);g.lineTo(x+w,y);g.lineTo(x+w,y-4);g.stroke();
g.fillStyle=C.dim;g.font=`11px ${FONT()}`;g.fillText(`${km.toLocaleString("en-US")} km${view==="globe"?" at centre":""}`,x+w+8,y);
}
g.restore();
}
/* light pulses: each photon runs its route and back at the speed of light in glass; the ring at the start is the real reply */
const SLOW=24;
function along(proj,p,f){
const r=rt(p),cum=r.cum,pts=r.path,x=f*cum[cum.length-1];
let lo=0,hi=cum.length-1;while(hi-lo>1){const mid=(lo+hi)>>1;if(cum[mid]<=x)lo=mid;else hi=mid}
const seg=cum[hi]-cum[lo],u=seg>0?(x-cum[lo])/seg:0;
if(S.view==="globe"){const g=d3.geoInterpolate(pts[lo],pts[hi])(u);return d3.geoDistance(g,center())<Math.PI/2?proj(g):null}
const a=proj(pts[lo]),b=proj(pts[hi]);return[a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u];
}
function pulses(proj){
if(!S.pulses||anim)return;
const now=performance.now(),list=shown();
ctx.save();ctx.globalCompositeOperation="lighter";
for(const p of list){
const sel=p.idx===S.sel,r=rt(p),fl=r.fl,per=Math.max(fl,p.T)*SLOW+900,ms=((now+p.idx*733)%per)/SLOW;
if(ms<fl){
const f=ms<fl/2?ms/(fl/2):1-(ms-fl/2)/(fl/2),q=along(proj,p,f);
if(q){ctx.fillStyle=C.photon;ctx.globalAlpha=sel?.35:.16;ctx.beginPath();ctx.arc(q[0],q[1],sel?10:6,0,2*Math.PI);ctx.fill();
ctx.globalAlpha=sel?1:.85;ctx.beginPath();ctx.arc(q[0],q[1],sel?2.8:1.6,0,2*Math.PI);ctx.fill()}
}
if(ms>=p.T&&ms<p.T+14){const A=LS[p.from];if(visible(A.lon,A.lat)){const q=proj([A.lon,A.lat]),age=(ms-p.T)/14;
ctx.strokeStyle=TC()[r.tier];ctx.globalAlpha=(1-age)*(sel?1:.65);ctx.lineWidth=sel?2.2:1.3;ctx.beginPath();ctx.arc(q[0],q[1],4+age*(sel?38:20),0,2*Math.PI);ctx.stroke()}}
}
ctx.restore();
}
function frame(){
if(!Wd)return;
ctx.setTransform(DPR,0,0,DPR,0,0);
if(anim){
const t=d3.easeCubicInOut(Math.min(1,(performance.now()-anim.t0)/anim.dur));
ctx.fillStyle=C.bg;ctx.fillRect(0,0,Wd,Ht);
if(anim.from==="globe"||anim.to==="globe"){paint(ctx,projFor(anim.from),anim.from,false,1-t);paint(ctx,projFor(anim.to),anim.to,false,t)}
else paint(ctx,mixProj(anim.from,anim.to,t),S.view,true);
if(t>=1){anim=null;bgKey="";viewBtns()}
return;
}
if(tween){const u=d3.easeCubicInOut(Math.min(1,(performance.now()-tween.t0)/tween.dur)),f=tween.from,o=tween.to;
T[S.view]={k:f.k*Math.pow(o.k/f.k,u),x:f.x+(o.x-f.x)*u,y:f.y+(o.y-f.y)*u};
if(S.view==="globe"){const c=d3.geoInterpolate([-f.rot[0],-f.rot[1]],[-o.rot[0],-o.rot[1]])(u);globeRot=[-c[0],-c[1]]}
if(u>=1){tween=null;viewBtns()}}
const tt=T[S.view],key=[S.view,S.mode,tt.k,tt.x,tt.y,globeRot,S.sel,S.hover,S.site,S.onlyBad,Wd,Ht].join("|");
if(key!==bgKey){bgx.setTransform(DPR,0,0,DPR,0,0);bgx.fillStyle=C.bg;bgx.fillRect(0,0,Wd,Ht);paint(bgx,projFor(S.view),S.view,false);bgKey=key}
ctx.setTransform(1,0,0,1,0,0);ctx.drawImage(bg,0,0);ctx.setTransform(DPR,0,0,DPR,0,0);
pulses(projFor(S.view));
}
let running=false,inView=true;
function loop(){frame();if((S.pulses&&inView&&!document.hidden)||anim||tween)requestAnimationFrame(loop);else running=false}
function kick(){if(!running){running=true;requestAnimationFrame(loop)}}
new IntersectionObserver(e=>{inView=e[0].isIntersecting;if(inView)kick()}).observe(hero);
document.addEventListener("visibilitychange",()=>{if(!document.hidden)kick()});
function setView(v){
if(v===S.view)return;const from=S.view;S.view=v;tween=null;FIT[v]=fitT(v);T[v]={...FIT[v]};
document.querySelectorAll(".models button").forEach(b=>b.classList.toggle("on",b.dataset.v===v));
if(!reduce)anim={from,to:v,t0:performance.now(),dur:from==="globe"||v==="globe"?500:950};else bgKey="";
readout();renderCard();kick();frame();
}
function readout(){
const n=liveBad(S.view),el=document.getElementById("rbig"),from=+el.textContent||0;
const nv=liveVac(S.view);el.className="big num "+(nv?"bad":n?"fib":"ok");
document.getElementById("rlab").innerHTML=`routes inconsistent with this model<small>of ${LP.length} measured by ${SRC[S.src].label}, using ${S.mode==="cable"?"sea-cable routes":"straight lines"} (${liveBad(S.view,S.mode==="cable"?"line":"cable")} using ${S.mode==="cable"?"straight lines":"sea-cable routes"}): <span class="fib">${n-nv} faster than light in fibre</span>, <span class="bad">${nv} faster than light in vacuum</span>. <u id="rlist">Show list</u></small>`;
document.getElementById("rlist").onclick=openList;
if(reduce){el.textContent=n;return}
const t0=performance.now();(function step(){const t=Math.min(1,(performance.now()-t0)/600);el.textContent=Math.round(from+(n-from)*d3.easeCubicOut(t));if(t<1)requestAnimationFrame(step)})();
}
/* interaction */
function segDist(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy;let t=l?((px-a[0])*dx+(py-a[1])*dy)/l:0;t=Math.max(0,Math.min(1,t));return Math.hypot(px-a[0]-t*dx,py-a[1]-t*dy)}
function nearest(mx,my){
for(const [i,q] of siteHits)if(Math.hypot(q[0]-mx,q[1]-my)<9)return{site:i};
let best=null,bd=7;for(const [i,s] of hits)for(let j=1;j<s.length;j++){const d=segDist(mx,my,s[j-1],s[j]);if(d<bd){bd=d;best={pair:i}}}
return best;
}
let drag=null,hoverRAF=false;
cv.addEventListener("pointerdown",e=>{drag={x:e.clientX,y:e.clientY,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;
if(drag.moved){cv.classList.add("drag");tip.hidden=true;
if(S.view==="globe"){const s=90/T.globe.k;globeRot=[globeRot[0]+dx*s,Math.max(-89,Math.min(89,globeRot[1]-dy*s))]}else{T[S.view].x+=dx;T[S.view].y+=dy}
drag.x=e.clientX;drag.y=e.clientY;frame()}return}
if(hoverRAF)return;hoverRAF=true;requestAnimationFrame(()=>{hoverRAF=false});
const h=nearest(mx,my),hp=h&&h.pair!==undefined?h.pair:null;
if(hp!==S.hover){S.hover=hp;frame()}
if(h){const hr=hero.getBoundingClientRect();tip.hidden=false;tip.innerHTML=h.site!==undefined?`<b>${esc(LS[h.site].name)}</b><br>Select to show only this site's routes`:pairTip(LP[h.pair]);
const x=e.clientX-hr.left,y=e.clientY-hr.top;tip.style.left=Math.min(x+16,hr.width-tip.offsetWidth-8)+"px";tip.style.top=(y+16)+"px"}else tip.hidden=true;
});
cv.addEventListener("pointerup",e=>{const was=drag;drag=null;cv.classList.remove("drag");if(was&&was.moved)viewBtns();
if(was&&!was.moved){const r=cv.getBoundingClientRect(),h=nearest(e.clientX-r.left,e.clientY-r.top);
if(h&&h.site!==undefined){S.site=S.site===h.site?null:h.site;frame()}else if(h&&h.pair!==undefined)select(h.pair);else if(S.site!==null){S.site=null;frame()}}});
cv.addEventListener("pointerleave",()=>{tip.hidden=true;if(S.hover!==null){S.hover=null;frame()}});
cv.addEventListener("wheel",e=>{e.preventDefault();const r=cv.getBoundingClientRect();zoomAt(e.clientX-r.left,e.clientY-r.top,Math.exp(-e.deltaY*.0015))},{passive:false});
function zoomAt(mx,my,f){tween=null;const t=T[S.view],f0=FIT[S.view].k,k2=Math.max(f0*.6,Math.min(f0*40,t.k*f));f=k2/t.k;t.x=mx-(mx-t.x)*f;t.y=my-(my-t.y)*f;t.k=k2;frame();viewBtns()}
/* view buttons: zoom to the selected route, or go back to the starting view */
function goTo(to){
if(reduce){T[S.view]={k:to.k,x:to.x,y:to.y};if(S.view==="globe")globeRot=to.rot;frame();viewBtns();return}
tween={from:{...T[S.view],rot:globeRot.slice()},to,t0:performance.now(),dur:600};kick();
}
function homeView(){return{...FIT[S.view],rot:ROT0.slice()}}
function routeView(){
const pts=rt(LP[S.sel]).path,F=FIT[S.view];
if(S.view==="globe"){
const c=d3.geoCentroid({type:"LineString",coordinates:pts}),th=Math.max(.03,...pts.map(q=>d3.geoDistance(q,c)));
return{k:Math.max(F.k,Math.min(F.k*14,F.k*.8/Math.sin(Math.min(Math.PI/2,th)))),x:F.x,y:F.y,rot:[-c[0],-c[1]]};
}
const pr=baseProj(S.view).scale(1).translate([0,0]),xy=pts.map(q=>pr(q)).filter(q=>q&&isFinite(q[0])&&isFinite(q[1]));
const xs=xy.map(q=>q[0]),ys=xy.map(q=>q[1]),x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys),half=Math.PI*F.k;
const k=Math.max(F.k,Math.min(F.k*40,1.6*half/Math.max(x1-x0,1e-6),1.6*half/Math.max(y1-y0,1e-6)));
return{k,x:F.x-k*(x0+x1)/2,y:F.y-k*(y0+y1)/2,rot:globeRot.slice()};
}
function atHome(){const F=FIT[S.view],t=T[S.view];if(!F)return true;return Math.abs(t.k/F.k-1)<.002&&Math.abs(t.x-F.x)<1&&Math.abs(t.y-F.y)<1&&(S.view!=="globe"||d3.geoDistance([-globeRot[0],-globeRot[1]],[-ROT0[0],-ROT0[1]])<.002)}
function viewBtns(){document.getElementById("treset").disabled=atHome()}
function pairTip(p){const r=rt(p);return`<b>${esc(LS[p.a].name)} ↔ ${esc(LS[p.b].name)}</b><br><span class="num">${fmt(p.T)} ms</span> measured<br><span class="num">${fmt(r.fl)} ms</span> fibre floor along the ${MODE[S.mode]}, ${MNAME[S.view]}<br><span class="${TCLS[r.tier]}">${r.tier?`${TTXT[r.tier][0].toUpperCase()+TTXT[r.tier].slice(1)}: inconsistent with this model`:"At or above the fibre floor: consistent"}</span>`}
document.querySelectorAll(".models button").forEach(b=>b.onclick=()=>setView(b.dataset.v));
const tb=document.getElementById("tbad"),tp=document.getElementById("tpulse");
tb.onclick=()=>{S.onlyBad=!S.onlyBad;tb.setAttribute("aria-pressed",S.onlyBad);frame()};
tp.onclick=()=>{S.pulses=!S.pulses;tp.setAttribute("aria-pressed",S.pulses);kick();frame()};
tp.setAttribute("aria-pressed",S.pulses);
document.getElementById("tzoom").onclick=()=>goTo(routeView());
document.getElementById("treset").onclick=()=>goTo(homeView());
document.getElementById("tin").onclick=()=>zoomAt(Wd/2,Ht/2,1.5);
document.getElementById("tout").onclick=()=>zoomAt(Wd/2,Ht/2,1/1.5);
/* route card: one route, or every route this map can't explain */
let raceT0=0;
function select(i,keepTab){S.sel=i;S.hover=null;if(!keepTab)S.tab="route";raceT0=performance.now();renderCard();renderEx();frame();if(!reduce&&S.tab==="route")requestAnimationFrame(race)}
function openList(){S.tab="list";renderCard();if(!wide())document.getElementById("card").scrollIntoView({behavior:reduce?"auto":"smooth"})}
function renderCard(){
const card=document.getElementById("card"),p=LP[S.sel],bad=LP.filter(q=>rt(q).bad).sort((x,y)=>(rt(y).fl-y.T)-(rt(x).fl-x.T));
const tabs=`<div class="tabs"><button data-t="route" class="${S.tab==="route"?"on":""}">Route</button><button data-t="list" class="${S.tab==="list"?"on":""}">Inconsistent (${bad.length})</button></div>`;let h="";
if(S.tab==="list"){
h+=bad.length?`<p class="foot" style="margin:0 0 8px">Routes inconsistent with the ${MNAME[S.view]} model, in order of margin. Routes that share cables overlap on the map; select one to highlight it.</p><div class="list">`+
bad.map(q=>{const r=rt(q);return`<div class="li${q.idx===S.sel?" on":""}" data-i="${q.idx}" tabindex="0"><b>${esc(LS[q.a].name)} ↔ ${esc(LS[q.b].name)}</b><em class="num ${TCLS[r.tier]}">${fmt(r.fl-q.T)} ms below floor</em><span style="grid-column:1/-1"><span class="${TCLS[r.tier]}">${TTXT[r.tier]}</span> · <span class="num">measured ${fmt(q.T)} ms · fibre floor ${fmt(r.fl)} ms · ${fint(r.km)} km</span></span></div>`}).join("")+`</div>`
:`<p class="foot">Every measured route is consistent with this model.</p>`;
card.innerHTML=tabs+`<div class="body">${h}</div>`;
}else{
const r0=rt(p),max=Math.max(p.T,...MODELS.map(([m])=>rt(p,m).fl))*1.05;
const via=S.mode==="line"?"straight line between the two sites":(r0.via.length?`via ${r0.via.slice(0,3).map(esc).join(", ")}${r0.via.length>3?` and ${r0.via.length-3} more`:""}`:"overland")+(r0.land>1?` · ${fint(r0.land)} km overland, as straight lines`:"");
h+=`<h2>${esc(LS[p.a].name)} ↔ ${esc(LS[p.b].name)}</h2><div class="via">${MNAME[S.view]} route: ${via}</div>
<div class="meas"><b class="num">${fmt(p.T)}</b><span>ms measured round-trip time (${/Vultr|^home/.test(p.cells[0].net)?`<b style="color:var(--ink);font:inherit">${esc(netName(p.cells[0].net))}</b>`:"Globalping probe"})</span></div>
<div class="rows">${MODELS.map(([m,n])=>{const r=rt(p,m);return`<div class="row${m===S.view?" cur":""}" data-m="${m}"><div class="top"><b>${n}<span class="inl"> · ${fint(r.km)} km</span></b><em class="num ${TCLS[r.tier]}">${r.bad?`${fmt(r.fl-p.T)} ms below ${r.vac?"vacuum floor":"floor"}`:`${fmt(p.T-r.fl)} ms above floor`}</em></div>
<div class="top sub" style="color:var(--dim)"><span class="num">${fint(r.km)} km</span><span class="num">floor ${fmt(r.fl)} ms</span></div>
<div class="track"><div class="fill" style="width:${r.fl/max*100}%;background:${TC()[r.tier]}"></div><div class="tick" style="left:${p.T/max*100}%"></div></div></div>`}).join("")}</div>
<p class="foot">Bar: round-trip fibre floor along each model's ${MODE[S.mode]}. White mark: measured round-trip time.</p>`;
card.innerHTML=tabs+`<div class="body">${h}</div>`;card.dataset.max=max;fitKm();
}
card.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{S.tab=b.dataset.t;renderCard()});
card.querySelectorAll(".li").forEach(el=>{const go=()=>select(+el.dataset.i,true);el.onclick=go;el.onkeydown=e=>{if(e.key==="Enter")go()}});
}
/* drop the inline distances when any row would truncate them */
function fitKm(){const card=document.getElementById("card");card.classList.remove("nokm");if([...card.querySelectorAll(".row .top b")].some(b=>b.scrollWidth>b.clientWidth+1))card.classList.add("nokm")}
function race(){
if(S.tab!=="route")return;
const p=LP[S.sel],max=+document.getElementById("card").dataset.max,el=(performance.now()-raceT0)/1400*max;
document.querySelectorAll("#card .row").forEach(r=>{const m=r.dataset.m;r.querySelector(".fill").style.width=Math.min(rt(p,m).fl,el)/max*100+"%";r.querySelector(".tick").style.opacity=el>=p.T?1:0});
if(el<max)requestAnimationFrame(race);
}
/* ---------- mode toggle + legend ---------- */
function legend(){
document.getElementById("legend").innerHTML=`<span><i style="background:var(--ok)"></i>consistent</span><span><i style="background:var(--fib)"></i>faster than light in fibre</span><span><i style="background:var(--bad)"></i>faster than light in vacuum</span><span><i style="background:var(--sel);height:4px"></i>selected</span><span><i style="background:var(--photon);width:7px;height:7px;border-radius:50%;box-shadow:0 0 6px var(--photon)"></i>light in fibre</span>`+
(S.mode==="cable"?`<span><i style="background:repeating-linear-gradient(90deg,var(--dim) 0 4px,transparent 4px 7px)"></i>overland, straight line</span><span><i style="background:var(--cable);height:1px"></i>submarine cable</span>`:`<span><i style="background:var(--dim)"></i>straight line between the sites</span>`);
}
function setSource(src){
const cur=LP[S.sel],key=cur?cur.a+"-"+cur.b:"";S.src=src;LP=LPS[src];S.hover=null;S.site=null;
const ia=LS.findIndex(s=>s.name==="Sydney"),ib=LS.findIndex(s=>s.name==="Auckland");
const worst=LP.slice().sort((x,y)=>(rt(y).fl-y.T)-(rt(x).fl-x.T))[0];
S.sel=(LP.find(p=>p.a+"-"+p.b===key)||LP.find(p=>(p.a===ia&&p.b===ib)||(p.a===ib&&p.b===ia))||worst).idx;
document.getElementById("srcsel").value=src;
bgKey="";readout();renderCard();renderScore();drawSM();drawSpeed();renderLadder();renderEx();renderMethod();renderFlat();frame();
}
document.getElementById("srcsel").onchange=e=>setSource(e.target.value);
function setMode(md){S.mode=md;document.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===md));legend();readout();renderCard();renderScore();drawSM();drawSpeed();frame()}
document.querySelectorAll("#mode button").forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
document.getElementById("rbig").onclick=openList;
/* ---------- the speed limit ---------- */
const need=(p,m)=>{const r=rt(p,m);return 2*Math.max(0,r.km-25)/(p.T/1000)}; // km/s the reply implies on that map
function renderLadder(){
const med=a=>{a=a.slice().sort((x,y)=>x-y);return a[Math.floor(a.length/2)]},typ=med(LP.map(p=>need(p,"globe")));
const rows=[["Light in vacuum",CV,C.ink,"upper limit"],["Radio and microwave in air",CV/1.0003,C.dim,"within 0.03% of vacuum"],
["Light in single-mode fibre",CF,C.photon,`floor used here · ${Math.round(CF/CV*100)}% of vacuum`],["Median implied speed, globe",typ,C.ok,`along sea-cable routes · ${Math.round(typ/CF*100)}% of fibre`]];
document.getElementById("ladder").innerHTML=rows.map(([n,v,c,sub])=>`<div class="lad"><div class="t"><span><b>${n}</b> <span style="color:var(--dim);font-size:12px">${sub}</span></span><span class="num">${fint(v)} km/s</span></div><div class="bar" style="width:${v/CV*100}%;background:${c}"></div></div>`).join("");
}
let SP=[];
function drawSpeed(){
const c=document.getElementById("speedcv"),r=c.getBoundingClientRect(),w=r.width,h=r.height;if(!w)return;
c.width=Math.round(w*DPR);c.height=Math.round(h*DPR);const g=c.getContext("2d");g.setTransform(DPR,0,0,DPR,0,0);
const vmax=Math.max(600000,Math.ceil(Math.max(...LP.flatMap(p=>MODELS.map(([m])=>need(p,m))))/100000)*100000);
const pl=w<560?92:132,pr=14,pt=34,pb=30,iw=w-pl-pr,rh=(h-pt-pb)/4,X=v=>pl+Math.min(v,vmax)/vmax*iw;
g.fillStyle="#050a12";g.fillRect(0,0,w,h);
g.globalAlpha=.09;g.fillStyle=C.fib;g.fillRect(X(CF),pt-6,X(CV)-X(CF),h-pt-pb+6);g.fillStyle=C.bad;g.globalAlpha=.13;g.fillRect(X(CV),pt-6,X(vmax)-X(CV),h-pt-pb+6);g.globalAlpha=1;
g.font=`10.5px ${FONT()}`;g.fillStyle=C.faint;g.textAlign="center";g.strokeStyle=C.rule;
for(let v=0;v<=vmax;v+=100000){g.beginPath();g.moveTo(X(v),pt-6);g.lineTo(X(v),h-pb);g.stroke();g.fillText(v?`${v/1000}k`:"0",X(v),h-pb+14)}
g.fillText("km/s",pl+iw/2,h-3);
for(const [v,col,t,al] of[[CF,C.photon,"light in fibre","right"],[CV,C.ink,"light in vacuum","left"]]){g.strokeStyle=col;g.lineWidth=1.5;g.beginPath();g.moveTo(X(v),pt-8);g.lineTo(X(v),h-pb);g.stroke();g.fillStyle=col;g.textAlign=al;g.fillText(t,X(v)+(al==="left"?5:-5),pt-14)}
g.lineWidth=1;SP=[];
MODELS.forEach(([m,n],k)=>{
const y0=pt+k*rh,cy=y0+rh/2;
g.strokeStyle=C.rule;g.beginPath();g.moveTo(pl,y0+rh);g.lineTo(pl+iw,y0+rh);g.stroke();
const vs=LP.map(p=>need(p,m)),fib=vs.filter(v=>v>CF).length,vac=vs.filter(v=>v>CV).length;
g.fillStyle=C.ink;g.textAlign="right";g.font=`600 12px ${FONT()}`;g.fillText(n,pl-10,cy-8);
g.font=`10.5px ${FONT()}`;if(!fib){g.fillStyle=C.ok;g.fillText("all consistent",pl-10,cy+6)}
else{g.fillStyle=C.fib;g.fillText(`${fib-vac} exceed fibre`,pl-10,cy+6);g.fillStyle=C.bad;g.fillText(`${vac} exceed vacuum`,pl-10,cy+19)}
LP.forEach((p,i)=>{const v=vs[i],t=v>CV?2:v>CF?1:0,x=X(v),y=cy+((i*37)%17-8)*rh/26;g.beginPath();g.arc(x,y,3,0,2*Math.PI);g.fillStyle=TC()[t];g.globalAlpha=t?.95:.6;g.fill();g.globalAlpha=1;SP.push([x,y,p.idx,m,v])});
});
const cnt=m=>LP.filter(p=>need(p,m)>CV).length;
document.getElementById("speedsub").textContent=`Each point is one live route: its length on the given model (${MODE[S.mode]}) divided by half the measured round-trip time. Uses the selected route type and data source.`;
document.getElementById("speednote").innerHTML=`Left of the gold line, light in fibre suffices (cyan). Between the lines, the implied speed exceeds that of light in fibre (orange). Right of the white line, it exceeds that of light in vacuum (red). On the globe every route lies left of the gold line; the fastest implies ${Math.round(Math.max(...LP.map(p=>need(p,"globe")))/CF*100)}% of the speed of light in fibre.`;
}
document.getElementById("speedcv").addEventListener("pointermove",e=>{
const c=e.currentTarget,r=c.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top,tp=document.getElementById("spt");
let b=null,bd=8;for(const q of SP){const d=Math.hypot(q[0]-mx,q[1]-my);if(d<bd){bd=d;b=q}}
if(!b){tp.hidden=true;return}
const p=LP[b[2]];tp.hidden=false;tp.innerHTML=`<b>${esc(LS[p.a].name)} ↔ ${esc(LS[p.b].name)}</b>, ${MNAME[b[3]]}<br><span class="num">${fint(b[4])} km/s</span> implied, ${Math.round(b[4]/CF*100)}% of light in fibre`;
tp.style.left=Math.min(b[0]+14,r.width-tp.offsetWidth)+"px";tp.style.top=(b[1]+40)+"px";
});
document.getElementById("speedcv").addEventListener("pointerleave",()=>{document.getElementById("spt").hidden=true});
/* ---------- how each map measures distance ---------- */
function renderEx(){
const box=document.getElementById("ex");
if(!box.children.length)box.innerHTML=MODELS.map(([m,n])=>`<div><h3>${n}</h3><canvas id="ex-${m}"></canvas><p id="exp-${m}"></p></div>`).join("");
const p=LP[S.sel],A=LS[p.a],B=LS[p.b];
document.getElementById("exlede").innerHTML=`Selected route: ${esc(A.name)} ↔ ${esc(B.name)}. On a flat model, no cable can be shorter than the straight line between its endpoints. Select a route on the map to change the pair.`;
for(const [m] of MODELS){
const c=document.getElementById("ex-"+m),r=c.getBoundingClientRect(),w=r.width,h=r.height;if(!w)continue;
c.width=Math.round(w*DPR);c.height=Math.round(h*DPR);const g=c.getContext("2d");g.setTransform(DPR,0,0,DPR,0,0);
const rad=w/2-8,mid=d3.geoInterpolate([A.lon,A.lat],[B.lon,B.lat])(.5);
const proj=m==="globe"?d3.geoOrthographic().rotate([-mid[0],-mid[1]]).scale(rad).translate([w/2,h/2]):baseProj(m).scale(rad/Math.PI).translate([w/2,h/2]);
const path=d3.geoPath(proj,g);
g.fillStyle="#050a12";g.fillRect(0,0,w,h);
g.beginPath();if(m==="merc")g.rect(w/2-rad,h/2-rad,2*rad,2*rad);else g.arc(w/2,h/2,rad,0,2*Math.PI);g.fillStyle=C.deep;g.fill();g.strokeStyle="#1a2a40";g.lineWidth=1;g.stroke();
g.beginPath();path(grat);g.strokeStyle=C.grat;g.stroke();g.beginPath();path(landGeo);g.fillStyle=C.land;g.fill();
const pa=proj([A.lon,A.lat]),pb=proj([B.lon,B.lat]);g.font=`11px ${FONT()}`;
const lab=(x,y,t,col)=>{g.lineWidth=3;g.strokeStyle="#050a12";g.fillStyle=col;g.strokeText(t,x,y);g.fillText(t,x,y)};
if(m==="ae"||m==="dp"){
const ctr=m==="ae"?{lon:0,lat:90}:{lon:0,lat:0},pc=proj([ctr.lon,ctr.lat]);
g.setLineDash([4,4]);g.strokeStyle=C.dim;g.lineWidth=1;
for(const q of[pa,pb]){g.beginPath();g.moveTo(pc[0],pc[1]);g.lineTo(q[0],q[1]);g.stroke()}
g.setLineDash([]);g.fillStyle=C.ink;g.beginPath();g.arc(pc[0],pc[1],3,0,2*Math.PI);g.fill();
g.textAlign="center";lab(pc[0],pc[1]-8,m==="ae"?"North Pole":"0°, 0°",C.ink);
}
g.strokeStyle=C.sel;g.lineWidth=2.2;g.beginPath();
if(m==="globe")path({type:"LineString",coordinates:[[A.lon,A.lat],[B.lon,B.lat]]});else{g.moveTo(pa[0],pa[1]);g.lineTo(pb[0],pb[1])}
g.stroke();
for(const q of[pa,pb]){g.fillStyle=C.sel;g.beginPath();g.arc(q[0],q[1],3.5,0,2*Math.PI);g.fill()}
const sub=s=>m==="ae"?`${fint(R*(90-s.lat)*RAD)} km`:m==="dp"?`${fint(gc({lon:0,lat:0},s))} km`:"";
const close=Math.hypot(pa[0]-pb[0],pa[1]-pb[1])<130,upper=pa[1]<=pb[1];
for(const [q,s,i] of[[pa,A,0],[pb,B,1]]){
const tw=Math.max(g.measureText(s.name).width,g.measureText(sub(s)).width),lines=sub(s)?2:1;let x,y,al;
if(close){const above=(i===0)===upper;al="center";x=Math.min(w-6-tw/2,Math.max(6+tw/2,q[0]));y=above?q[1]-10-13*(lines-1):q[1]+18}
else{const left=(i===0)===(pa[0]<=pb[0]);al=left?"right":"left";x=q[0]+(left?-8:8);y=q[1]+4;
if(al==="left"&&x+tw>w-6){al="right";x=q[0]-8}if(al==="right"&&x-tw<6){al="left";x=q[0]+8}}
g.textAlign=al;lab(x,y,s.name,C.ink);if(sub(s))lab(x,y+13,sub(s),C.dim);
}
g.textAlign="left";
const d=dist(m,A,B);
document.getElementById("exp-"+m).innerHTML={
globe:`Sphere of radius 6,371 km. Shortest path: the great circle, <b class="num">${fint(d)} km</b>.`,
ae:`Each site lies at its true distance from the North Pole (dashed; km beneath each name), at the angle of its longitude. Straight-line distance: <b class="num">${fint(d)} km</b>.`,
dp:`Each site lies at its true distance and bearing from 0°N 0°E (dashed; km beneath each name); only these radial distances are true. Straight-line distance: <b class="num">${fint(d)} km</b>.`,
merc:`Longitude horizontal, latitude stretched vertically, true scale at the equator, no wrap across the 180° meridian. Straight-line distance: <b class="num">${fint(d)} km</b>.`}[m];
}
}
/* ---------- scoreboard ---------- */
function renderScore(){
const n=(v,of,vac)=>`<td class="n num ${vac===undefined?(v?"bad":"ok"):vac?"bad":v?"fib":"ok"}">${fint(v)}<small>/ ${fint(of)}</small>${vac!==undefined&&v?`<small class="br"><span class="fib">${fint(v-vac)} fibre</span> · <span class="bad">${fint(vac)} vacuum</span></small>`:""}</td>`;
const ml=`<br><span style="text-transform:none;letter-spacing:0">${S.mode==="cable"?"sea-cable routes":"straight lines"}</span>`;
let h=`<thead><tr><th>Map</th><th>Live measurements below floor${ml}<br><span style="text-transform:none;letter-spacing:0">${SRC[S.src].name}</span></th><th>WonderNetwork measurements below floor${ml}</th><th>Cables shorter than model distance<br><span style="text-transform:none;letter-spacing:0">published lengths</span></th><th>Cable crossings below floor<br><span style="text-transform:none;letter-spacing:0">traceroutes</span></th></tr></thead><tbody>`;
for(const [m,name] of MODELS)h+=`<tr><td class="m">${name}</td>${n(liveBad(m),LP.length,liveVac(m))}${n(wnBad(m),S.mode==="cable"?WN.n:WN.nAll,wnVac(m))}${n(CAB.filter(c=>cabBad(c,m)).length,CAB.length)}${n(xBad(m),XS.length,xVac(m))}</tr>`;
document.getElementById("score").innerHTML=h+"</tbody>";
const sv=LP.filter(p=>p.T<floorMs(Math.max(0,rt(p,"ae","line").km-25),CV)).length;
document.getElementById("scorenote").innerHTML=(S.mode==="cable"?`Measurement columns: fibre floor along each model's shortest sea-cable route, with overland segments as straight lines.`
:`Measurement columns: fibre floor along the straight line between the two sites on each model, the shortest possible cable.`)+
` With straight lines, North-pole AE is inconsistent with <b class="num">${liveBad("ae","line")}</b> live routes measured by ${SRC[S.src].label}, <b class="num">${sv}</b> of them below the vacuum floor. Cables: TeleGeography's published length compared with the distance between the cable's farthest landing points on each model.`;
}
/* ---------- scatter ---------- */
const SM={};
function buildSM(){
document.getElementById("sm").innerHTML=MODELS.map(([m,n])=>`<div class="fig"><h3>${n}</h3><div class="fc" id="fc-${m}"></div><canvas id="sc-${m}"></canvas><div class="stip" id="st-${m}" hidden></div></div>`).join("");
for(const [m] of MODELS){const c=document.getElementById("sc-"+m);SM[m]={c,pts:[]};c.addEventListener("pointermove",e=>smHover(m,e));c.addEventListener("pointerleave",()=>{document.getElementById("st-"+m).hidden=true})}
}
function drawSM(){
for(const [m] of MODELS){
const {c}=SM[m],r=c.getBoundingClientRect(),w=r.width,h=r.height;if(!w)continue;
c.width=Math.round(w*DPR);c.height=Math.round(h*DPR);const g=c.getContext("2d");g.setTransform(DPR,0,0,DPR,0,0);
const pl=40,pb=30,pt=8,pr=8,iw=w-pl-pr,ih=h-pt-pb,lo=0,hi=3;
const X=v=>pl+(Math.log10(Math.max(1,v))-lo)/(hi-lo)*iw,Y=v=>pt+ih-(Math.log10(Math.max(1,v))-lo)/(hi-lo)*ih;
g.fillStyle="#050a12";g.fillRect(0,0,w,h);
g.fillStyle=C.fib;g.globalAlpha=.08;g.beginPath();g.moveTo(X(1),Y(1));g.lineTo(X(1000),Y(1000));g.lineTo(X(1000),Y(1));g.fill();
g.fillStyle=C.bad;g.globalAlpha=.1;g.beginPath();g.moveTo(X(NF),Y(1));g.lineTo(X(1000),Y(1000/NF));g.lineTo(X(1000),Y(1));g.fill();g.globalAlpha=1;
g.strokeStyle=C.rule;g.lineWidth=1;g.font=`10.5px ${FONT()}`;g.fillStyle=C.faint;
for(const v of[1,10,100,1000]){g.beginPath();g.moveTo(X(v),pt);g.lineTo(X(v),pt+ih);g.moveTo(pl,Y(v));g.lineTo(pl+iw,Y(v));g.stroke();g.textAlign="center";g.fillText(v,X(v),h-pb+14);g.textAlign="right";g.fillText(v,pl-5,Y(v)+3.5)}
g.textAlign="center";g.fillText(`fibre floor along ${MODE[S.mode]}, ms`,pl+iw/2,h-3);
g.save();g.translate(10,pt+ih/2);g.rotate(-Math.PI/2);g.fillText("measured, ms",0,0);g.restore();
g.strokeStyle=C.photon;g.globalAlpha=.8;g.beginPath();g.moveTo(X(1),Y(1));g.lineTo(X(1000),Y(1000));g.stroke();
g.strokeStyle=C.ink;g.setLineDash([4,3]);g.beginPath();g.moveTo(X(NF),Y(1));g.lineTo(X(1000),Y(1000/NF));g.stroke();g.setLineDash([]);g.globalAlpha=1;
g.textAlign="right";g.fillStyle=C.photon;g.fillText("light in fibre",X(14)-3,Y(14)-6);g.textAlign="left";g.fillStyle=C.ink;g.fillText("vacuum",X(330)+4,Y(330/NF)+12);
const pts=[];
if(S.src==="all")for(const pass of[0,1])for(let i=0;i<WN.T.length;i++){if(S.mode==="cable"&&!WN.keep[i])continue;const b=WN.bad[S.mode][m][i];if(+b!==pass)continue;const fl=floorMs(Math.max(0,WN.km[S.mode][m][i]-200));if(fl<1)continue;
const t=WN.vac[S.mode][m][i]?2:b?1:0,x=X(fl),y=Y(WN.T[i]);g.fillStyle=TC()[t];g.globalAlpha=t?.8:.18;g.fillRect(x-1,y-1,2,2);pts.push([x,y,"w",i,fl,WN.T[i],t])}
g.globalAlpha=1;
for(const p of LP){const r=rt(p,m),x=X(r.fl),y=Y(p.T);g.beginPath();g.arc(x,y,3.4,0,2*Math.PI);g.fillStyle=TC()[r.tier];g.fill();g.lineWidth=1.5;g.strokeStyle="#050a12";g.stroke();pts.push([x,y,"l",p.idx,r.fl,p.T,r.tier])}
SM[m].pts=pts;
const wa=S.src==="all",k=(wa?wnBad(m):0)+liveBad(m),kv=(wa?wnVac(m):0)+liveVac(m);
document.getElementById("fc-"+m).innerHTML=k?`<span class="num fib">${fint(k-kv)}</span> below fibre floor · <span class="num bad">${fint(kv)}</span> below vacuum floor`:`<span class="ok">all consistent</span>`;
document.getElementById("smnote").textContent=S.src==="all"?"Logarithmic axes, milliseconds. Small points: WonderNetwork 2020 archive. Large points: live routes.":`Logarithmic axes, milliseconds. Points: live routes measured by ${SRC[S.src].label} only. Select All live sources to include the WonderNetwork 2020 archive.`;
document.getElementById("smlede").textContent=`Each point is one site pair. Points below the gold line are faster than light in fibre along the model's ${MODE[S.mode]} (orange); points below the dashed line are faster than light in vacuum (red). Uses the selected route type and data source.`;
}
}
function smHover(m,e){
const {c,pts}=SM[m],r=c.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top,tp=document.getElementById("st-"+m);
let b=null,bd=9;for(const q of pts){const d=Math.hypot(q[0]-mx,q[1]-my);if(d<bd){bd=d;b=q}}
if(!b){tp.hidden=true;return}
const nm=b[2]==="l"?`${esc(LS[LP[b[3]].a].name)} ↔ ${esc(LS[LP[b[3]].b].name)}`:`${esc(WS[WN.a[b[3]]].name)} ↔ ${esc(WS[WN.b[b[3]]].name)}`;
tp.hidden=false;tp.innerHTML=`<b>${nm}</b><br><span class="num">${fmt(b[5])} ms</span> measured<br><span class="num">${fmt(b[4])} ms</span> fibre floor along ${MODE[S.mode]}<br><span class="${TCLS[b[6]]}">${TTXT[b[6]]}</span>`;
tp.style.left=Math.max(0,Math.min(b[0]+16,r.width-tp.offsetWidth))+"px";tp.style.top=(b[1]+44)+"px";
}
/* ---------- crossings ---------- */
function renderX(){
const max=Math.max(...XS.map(t=>floorMs(t.c.route.ae)));
document.getElementById("xs").innerHTML=XS.map(t=>{const g=floorMs(t.c.route.globe),a=floorMs(t.c.route.ae),tr=t.jump<floorMs(t.c.route.ae,CV)?2:t.jump<a?1:0,pc=v=>v/max*100;
return`<div class="xr" tabindex="0"><div class="xn"><b>${esc(t.src_city)} → ${esc(t.dst_city)}</b><span>${esc(t.c.name.replace(/ Cable$/,""))}${t.assumed?" (assumed)":""} · ${fint(t.c.length)} km published · measured ${fmt(t.jump,1)} ms · ${lnk(`https://atlas.ripe.net/measurements/${t.msm}/`,`RIPE ${t.msm}`)}, ${lnk(`https://atlas.ripe.net/api/v2/measurements/${t.msm}/results/?probe_ids=${t.probe}&start=${t.window[0]}&stop=${t.window[1]}&format=json`,`probe ${t.probe} results`)}</span></div>
<div class="xb"><span class="lb num ok" style="top:0">globe ${fmt(g)} ms</span><div class="bar" style="top:15px;width:${pc(g)}%;background:var(--ok)"></div>
<span class="lb num ${TCLS[tr]}" style="top:28px">North-pole AE ${fmt(a)} ms · ${["consistent","below fibre floor","below vacuum floor"][tr]}</span><div class="bar" style="top:43px;width:${pc(a)}%;background:${TC()[tr]}"></div>
<div class="tk" style="left:${pc(t.jump)}%"></div></div></div>`}).join("");
document.querySelectorAll(".xr").forEach((el,k)=>{const go=()=>{const t=XS[k],a=LS.findIndex(s=>t.src_city.startsWith(s.name)),b=LS.findIndex(s=>t.dst_city.startsWith(s.name));
const p=LP.find(p=>(p.a===a&&p.b===b)||(p.a===b&&p.b===a));if(p){select(p.idx);hero.scrollIntoView({behavior:reduce?"auto":"smooth"})}};el.onclick=e=>{if(!e.target.closest("a"))go()};el.onkeydown=e=>{if(e.key==="Enter"&&!e.target.closest("a"))go()}});
}
/* ---------- ping it yourself ---------- */
const BC=D.byo.cities,BA=D.byo.anchors;
function findLoc(str){
str=(str||"").trim();if(!str)return null;
const m=/^(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)$/.exec(str);
if(m){const lat=+m[1],lon=+m[2];return Math.abs(lat)<=90&&Math.abs(lon)<=180?{name:`${lat}, ${lon}`,lat,lon}:null}
const [n,cc]=str.split(/,\s*/),lc=n.toLowerCase();
const hit=BC.find(c=>c[0].toLowerCase()===lc&&(!cc||c[1].toLowerCase()===cc.toLowerCase()))||BC.find(c=>c[0].toLowerCase()===lc)||BC.find(c=>c[0].toLowerCase().startsWith(lc));
return hit?{name:`${hit[0]}, ${hit[1]}`,lat:hit[2],lon:hit[3]}:null;
}
let byoExample=true;
function byoCmd(){const a=BA[document.getElementById("byoB").value];document.getElementById("byocmd").textContent=`ping -c 20 ${a[4]} # macOS, Linux\nping -n 20 ${a[4]} # Windows\n# ${a[5]}, ${a[0]}. Enter the minimum "time=" value.`}
function byoRun(e){
if(e){e.preventDefault();byoExample=false}
const out=document.getElementById("byoout"),a=BA[document.getElementById("byoB").value],A=findLoc(document.getElementById("byoA").value),B={name:`${a[0]} anchor`,lat:a[2],lon:a[3]},Tm=+document.getElementById("byoT").value;
if(!A){out.innerHTML=`<p class="verdict">Location not found. Select a city from the list or enter coordinates, for example <span class="num">-36.85, 174.76</span>.</p>`;return}
if(!(Tm>0)){out.innerHTML=`<p class="verdict">Enter the minimum round-trip time in milliseconds.</p>`;return}
const bad=[];const rows=MODELS.map(([m,n])=>{const km=dist(m,A,B),f=floorMs(km),t=Tm<floorMs(km,CV)?2:Tm<f?1:0;if(t)bad.push(n);return`<tr><td>${n}</td><td class="num">${fint(km)}</td><td class="num">${fmt(f)}</td><td class="${TCLS[t]}">${t?`${fmt(f-Tm)} ms below floor: ${TTXT[t]}`:"consistent"}</td></tr>`}).join("");
out.innerHTML=`${byoExample?'<div class="eyebrow">Example: a measurement from the live survey</div>':""}<p class="verdict"><b>${esc(A.name)} → ${esc(B.name)}, <span class="num">${fmt(Tm)} ms</span>.</b> ${bad.length?`<span class="bad">Inconsistent with ${bad.join(", ")}.</span>`:`<span class="ok">Consistent with every model.</span> A time above every floor cannot exclude a model.`}</p>
<div class="tw"><table class="mini"><thead><tr><th>Map</th><th>Straight line, km</th><th>Fibre floor, ms</th><th></th></tr></thead><tbody>${rows}</tbody></table></div><p class="note">Straight line: the shortest possible cable, and therefore the most conservative test for each model.</p>`;
}
function byoInit(){
document.getElementById("byocities").innerHTML=BC.map(c=>`<option value="${esc(c[0])}, ${c[1]}">`).join("");
const sel=document.getElementById("byoB");sel.innerHTML=BA.map((a,i)=>`<option value="${i}">${esc(a[0])}, ${a[1]} · ${a[4]}</option>`).join("");
sel.value=String(BA.findIndex(a=>a[0]==="Sydney"));document.getElementById("byoA").value="Auckland, NZ";document.getElementById("byoT").value="25.2";
sel.onchange=byoCmd;document.getElementById("byo").addEventListener("submit",byoRun);byoCmd();byoRun();
}
/* ---------- raw records, code and data ---------- */
function rawRef(c){const r=D.live.runs[c.run];
if(r.source==="vultr")return lnk(`${SRCV}src/data/vps/${c.ref}/ping.log`,`Vultr ${c.ref} log`);
if(r.source==="home")return lnk(`${SRCV}src/data/home/ping.log`,"residential log");
const gi=D.live.runs.filter(x=>!x.source).indexOf(r)+1;
return`${lnk(SRCV+"src/"+r.file,`Globalping run ${gi}`)} · ${lnk(`https://api.globalping.io/v1/measurements/${c.ref}`,c.ref.slice(0,10)+"…")}`}
function renderCode(){
const gp=D.live.runs.filter(r=>!r.source),vps=[...new Set(D.live.cells.filter(c=>/Vultr/.test(c[5])).map(c=>c[6]))].sort();
const f=(p,t)=>lnk(SRCV+"src/"+p,t||p),py=[["models.py","distance on each model, constants"],["measure.py","Globalping measurements"],["vps.py","Vultr servers: create, measure, collect, delete"],["vps_analyze.py","Vultr logs to measurements"],
["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"],
["flatfit.py","best attainable flat arrangements"],["asmap.py","network attribution from public BGP data"],["pingmap.py","shape fit without positions"],["build.py","this page"],["export.py","JSON data hand-off"]];
document.getElementById("code").innerHTML=`
<div>Repository</div><div>${lnk(GIT,GIT.replace("https://",""))}</div>
<div>Data hand-off</div><div>${lnk(RAWV+"cable_latency_survey_data_v2.json","cable_latency_survey_data_v2.json")} (every measurement, site, route and cable path) · ${lnk(RAWV+"cable_latency_survey_data.json","version 1")}</div>
<div>Globalping</div><div>${gp.map((r,i)=>f(r.file,`run ${i+1}`)).join(" · ")}: raw results; each carries its Globalping measurement ID</div>
<div>Vultr servers</div><div>${vps.map(v=>`${v}: ${f(`data/vps/${v}/ping.log`,"ping")}, ${f(`data/vps/${v}/mtr.log`,"traceroute")}`).join(" · ")}</div>
<div>Residential connection</div><div>${f("data/home/ping.log","ping log")}</div>
<div>RIPE Atlas</div><div>${f("data/trace.json","sea-crossing traceroutes")} (hops, timings, measurement and probe IDs)</div>
<div>WonderNetwork</div><div>${lnk("https://wp-public-data.s3.amazonaws.com/pings/pings-2020-07-19-2020-07-20.csv.gz","original archive")} · ${f("data/wn_clean.json","per-pair statistics")}</div>
<div>Derived</div><div>${f("data/network.json","routes")} · ${f("data/cables.json","cables")} · ${f("data/flatfit.json","flat arrangements")} · ${f("data/asmap.json","networks")} · ${f("data/pingmap.json","shape fit")}</div>
<div>Pipeline</div><div>${py.map(([p,t])=>`${f(p)} ${t}`).join("<br>")}</div>
<div>Licences</div><div>${f("../LICENSE","MIT")} (code) · ${f("../LICENSE-DATA.md","CC BY 4.0 and third-party terms")} (data)</div>`;
document.getElementById("repro").textContent=`git clone ${GIT}.git
cd cable-latency-survey/src
./fetch_raw.sh # third-party inputs that are not redistributed
python3 build.py # this page, from the files in data/
python3 flatfit.py # flat arrangements (requires cvxpy)
python3 export.py # the JSON data hand-off`;
}
/* ---------- networks ---------- */
function renderNet(){
const N=D.net;if(!N)return;const S_=N.summary,asn=o=>`AS${String(o).replace(/_/g," / AS")}`;
document.getElementById("netsum").innerHTML=`${fint(S_.addresses)} distinct hop addresses on ${fint(N.traces)} Vultr traceroutes and ${N.crossings.length} RIPE Atlas crossings: <b class="num">${fint(S_.announced)}</b> announced in BGP by ${S_.networks} networks, ${S_.private} private or link-local, ${S_.unannounced} unannounced, of which ${S_.at_exchanges} belong to ${N.ixps.length} internet exchange points.`;
const vul=N.endpoints.filter(e=>e.kind==="vultr"),gp=N.gp;
document.getElementById("netend").innerHTML=`
<div>Vultr servers</div><div>${vul.filter(e=>e.match).length} of ${vul.length} addresses announced by ${asn(20473)} (${esc(vul[0].name)}); RPKI ${[...new Set(vul.flatMap(e=>e.rpki))].join(", ")}</div>
<div>Globalping probes</div><div>${gp.reduce((t,x)=>t+x.probes,0)} probes on ${gp.length} networks: ${gp.map(x=>`${asn(x.asn)} ${esc(x.network)} (${x.probes})`).join("; ")}</div>
<div>Residential connection</div><div>Network withheld with the location.</div>`;
const anc=N.endpoints.filter(e=>e.kind==="anchor");
document.getElementById("netanc").innerHTML=`<thead><tr><th>Anchor</th><th>Host, per RIPE Atlas</th><th>BGP origin</th><th>RPKI</th></tr></thead><tbody>`+anc.map(e=>`<tr><td>${esc(e.city)}<br><span style="color:var(--dim)">${esc(e.id)}</span></td><td>${/^\d*$/.test(e.company)?"not stated":esc(e.company)} (${asn(e.claimed_asn)})</td><td class="${e.match?"":"fib"}">${asn(e.bgp_origin)} ${esc(e.name)}<br><span style="color:var(--dim)">${esc(e.prefix)}</span></td><td>${e.rpki.join(", ")}</td></tr>`).join("")+"</tbody>";
document.getElementById("nettr").innerHTML=`<thead><tr><th>Network</th><th>Traceroutes</th></tr></thead><tbody>`+N.transit.map(x=>`<tr><td>${asn(x.asn)} ${esc(x.name)}</td><td class="num">${x.traces}</td></tr>`).join("")+"</tbody>";
document.getElementById("netix").innerHTML=`<thead><tr><th>Exchange point (PeeringDB)</th><th>Hop addresses</th></tr></thead><tbody>`+N.ixps.map(x=>`<tr><td>${esc(x.name)}</td><td class="num">${x.addresses}</td></tr>`).join("")+"</tbody>";
const side=s=>s.asn?`${asn(s.asn)} ${esc(s.name)}${s.owner===true?' · <span class="ok">cable owner</span>':s.owner===false?' · <span class="fib">not a listed owner</span>':""}`:"—";
document.getElementById("netx").innerHTML=`<thead><tr><th>Crossing</th><th>Cable and owners (TeleGeography)</th><th>Last announced hop before the jump</th><th>First announced hop after it</th></tr></thead><tbody>`+
N.crossings.map(x=>`<tr><td>${esc(x.src_city)} → ${esc(x.dst_city)}</td><td>${x.cable_name?`${esc(x.cable_name)}: ${esc(x.owners||"—")}`:"not identified"}</td><td>${side(x.before)}</td><td>${side(x.after)}</td></tr>`).join("")+"</tbody>";
document.getElementById("netnote").innerHTML=`AS names are the registrants' designations. The two anchors announced by a different AS than their name states are both hosted by DigiCert, which operates both numbers. RPKI "unknown" means the address holder has published no route origin authorisation; no announcement was invalid. Ownership tokens are matched against AS names, so a network that leases capacity on a cable appears as "not a listed owner". Code: ${lnk(SRCV+"src/asmap.py","asmap.py")}; results: ${lnk(SRCV+"src/data/asmap.json","asmap.json")}, ${lnk(SRCV+"src/data/rpki.json","rpki.json")}.`;
}
/* ---------- best attainable flat arrangements ---------- */
let FFB=0;
const FFM=[["ae","North-pole AE","ae"],["dp","Dual-pole AE","dualpole"],["merc","Mercator","mercator"]];
function renderFlat(){
if(!D.flat)return;
const box=document.getElementById("ff"),F=D.flat.fits[S.src],bud=D.flat.budgets_km;
if(!box.children.length)box.innerHTML=FFM.map(([m,n])=>`<div><h3>${n}</h3><canvas id="ff-${m}" aria-label="Corrected ${n} arrangement"></canvas><p id="ffp-${m}"></p></div>`).join("");
document.getElementById("ffbv").textContent=FFB===0?"0 km (as mapped)":FFB<bud.length?`${fint(bud[FFB])} km`:"minimum for full consistency";
for(const [m,n,k] of FFM){
const f=F[k],row=f.budgets[Math.min(FFB,f.budgets.length-1)],c=document.getElementById("ff-"+m),r=c.getBoundingClientRect(),w=r.width,h=r.height;if(!w)continue;
c.width=Math.round(w*DPR);c.height=Math.round(h*DPR);const g=c.getContext("2d");g.setTransform(DPR,0,0,DPR,0,0);
const rad=w/2-8,proj=baseProj(m).scale(rad/Math.PI).translate([w/2,h/2]),path=d3.geoPath(proj,g);
g.fillStyle="#050a12";g.fillRect(0,0,w,h);
g.beginPath();if(m==="merc")g.rect(w/2-rad,h/2-rad,2*rad,2*rad);else g.arc(w/2,h/2,rad,0,2*Math.PI);g.fillStyle=C.deep;g.fill();g.strokeStyle="#1a2a40";g.lineWidth=1;g.stroke();
g.beginPath();path(grat);g.strokeStyle=C.grat;g.stroke();g.beginPath();path(landGeo);g.fillStyle=C.land;g.fill();
const at=f.sites.map(s=>proj([LS[s].lon,LS[s].lat])),to=row.pos.map(q=>proj(q)),ix=Object.fromEntries(f.sites.map((s,i)=>[s,i]));
g.lineWidth=1.5;for(const [a,b,t] of row.inconsistent){const p1=to[ix[a]],p2=to[ix[b]];g.strokeStyle=t===2?C.bad:C.fib;g.beginPath();g.moveTo(p1[0],p1[1]);g.lineTo(p2[0],p2[1]);g.stroke()}
g.strokeStyle=C.dim;g.lineWidth=1;for(let i=0;i<at.length;i++){if(Math.hypot(at[i][0]-to[i][0],at[i][1]-to[i][1])<1)continue;g.beginPath();g.moveTo(at[i][0],at[i][1]);g.lineTo(to[i][0],to[i][1]);g.stroke()}
for(let i=0;i<at.length;i++){g.beginPath();g.arc(at[i][0],at[i][1],3.5,0,2*Math.PI);g.strokeStyle=C.dim;g.lineWidth=1.2;g.stroke();g.beginPath();g.arc(to[i][0],to[i][1],2.6,0,2*Math.PI);g.fillStyle=C.sel;g.fill()}
const mv=f.sites.map((s,i)=>[Math.hypot(at[i][0]-to[i][0],at[i][1]-to[i][1]),i]).filter(x=>x[0]>6).sort((x,y)=>y[0]-x[0]).slice(0,4);
g.font=`11px ${FONT()}`;g.textAlign="left";const boxes=[];for(const [,i] of mv){const t=LS[f.sites[i]].name.replace(/ \(.*\)$/,""),tw=g.measureText(t).width,x=Math.max(4,Math.min(to[i][0]+6,w-6-tw)),y=to[i][1]-6,bx=[x-2,y-11,x+tw+2,y+3];if(boxes.some(q=>bx[0]<q[2]&&bx[2]>q[0]&&bx[1]<q[3]&&bx[3]>q[1]))continue;boxes.push(bx);g.lineWidth=3;g.strokeStyle="#050a12";g.strokeText(t,x,y);g.fillStyle=C.ink;g.fillText(t,x,y)}
const nb=row.inconsistent.length,nv=row.inconsistent.filter(x=>x[2]===2).length;
document.getElementById("ffp-"+m).innerHTML=`<b class="num ${nv?"bad":nb?"fib":"ok"}">${nb}</b> of ${f.routes} routes inconsistent${nb?` (${nv} faster than light in vacuum)`:""}${row.moved_max_km>0.5?` · largest correction <b class="num">${fint(row.moved_max_km)} km</b>`:""}`;
}
const one=(f,m,sp)=>{const b=f.one_pair_bound_km[sp];if(!(b.km>0))return"none needed";const [i,j]=b.pair,p=LP.find(q=>(q.a===Math.min(i,j)&&q.b===Math.max(i,j))),U=(sp==="fibre"?CF:CV)*p.T/2000+25,X=dist(m,LS[i],LS[j]);
return`${fint(b.km)} km: ${esc(LS[i].name)} ↔ ${esc(LS[j].name)} are ${fint(X)} km apart on the map; ${fmt(p.T)} ms allows at most ${fint(U)} km`};
document.getElementById("fftab").innerHTML=`<thead><tr><th>Map</th><th>Minimum correction, light in fibre</th><th>Minimum correction, light in vacuum</th><th>Bound from a single pair (no solver), fibre</th></tr></thead><tbody>`+
FFM.map(([m,n,k])=>{const f=F[k];return`<tr><td>${n}</td><td class="num">${fint(f.min_correction_km.fibre)} km</td><td class="num">${fint(f.min_correction_km.vacuum)} km</td><td>${one(f,m,"fibre")}</td></tr>`}).join("")+"</tbody>";
document.getElementById("ffnote").innerHTML=`Routes measured by ${SRC[S.src].label}; ${D.flat.slack_km} km is allowed at each end, as elsewhere on this page. A single pair bounds the correction from below without any solver: its two cities can approach each other by at most twice the correction. The minimum itself is computed with an interior-point solver (cvxpy, Clarabel); the arrangement it returns satisfies every measured pair. Code: ${lnk(SRCV+"src/flatfit.py","flatfit.py")}; results: ${lnk(SRCV+"src/data/flatfit.json","flatfit.json")}.`;
}
document.getElementById("ffb").addEventListener("input",e=>{FFB=+e.target.value;renderFlat()});
/* ---------- method ---------- */
function renderMethod(){
const best=rows=>rows.reduce((b,r)=>r[1]<b[1]?r:b);
const dp=best(D.sweep.dualpole),me=best(D.sweep.mercator);
const runs=D.live.runs.filter(r=>!r.source).map((r,i)=>`run ${i+1} ${r.started.replace("T"," ").replace("Z"," UTC")}`).join(", ");
const vr=D.live.runs.find(r=>r.source==="vultr"),vcities=[...new Set(D.live.cells.filter(c=>/Vultr/.test(c[5])).map(c=>LS[c[0]].name))];
document.getElementById("m1").innerHTML=`
<div>Cables</div><div>All in-service submarine cables on TeleGeography's Submarine Cable Map (${Object.keys(D.netlines).length}), followed waypoint by waypoint. The same latitude–longitude waypoints are used on every model, and each model measures them in its own geometry.</div>
<div>Land</div><div>Between any two landing points or sites on the same landmass: a straight line, the shortest possible terrestrial cable.</div>
<div>Route</div><div>Sea-cable routes: shortest path through the cable and land network, computed separately on each model, so that each flat model receives its own optimal route. Straight lines: the line between the two sites on that model, the shortest possible cable.</div>
<div>Speed</div><div>Light in standard single-mode fibre: 204,190 km/s (group index 1.4682). Round-trip floor = 2 × route length ÷ speed.</div>
<div>Allowance</div><div>Live measurements: each vantage point's round-trip time to the anchor in its own city is added to its measurements, and 25 km is subtracted from every route. WonderNetwork: 1 ms and 200 km.</div>
<div>Models</div><div>Globe: sphere of radius 6,371 km. North-pole AE: azimuthal equidistant projection centred on the North Pole, 111.2 km per degree of latitude. Dual-pole AE: azimuthal equidistant projection centred on 0°N 0°E, with both poles as points. Mercator: rectangular projection, true scale at the equator, bounded at 180°.</div>`;
document.getElementById("m2").innerHTML=`
<div>Globalping</div><div>Globalping probes in ${new Set(D.live.cells.filter(c=>!/Vultr|^home/.test(c[5])).map(c=>c[0])).size} cities measured round-trip times to RIPE Atlas anchors, 16 packets per measurement: ${runs}.</div>
${vr?`<div>Vultr servers</div><div>Virtual servers rented for this survey in ${vcities.map(esc).join(", ")} measured round-trip times to each other and to the anchors every 10 minutes, 20 packets per measurement, from ${esc(vr.started.replace("T"," ").replace("Z"," UTC"))} to ${esc(vr.ended.slice(11,16))} UTC (${vr.rounds} rounds), with traceroutes every 30 minutes. Each server is assigned the position of the anchor in its city, with its round-trip time to that anchor as the allowance. Honolulu has no anchor; that server is placed at the data-centre city.</div>`:""}
<div>Residential connection</div><div>A residential connection in Michigan measured round-trip times to the same anchors and to the Vultr servers, two rounds of 20 packets. Its exact location is withheld; it is assigned the position of the nearest anchor by round-trip time, in Southfield, MI, with that time (16.6 ms) as the allowance.</div>
<div>WonderNetwork</div><div>Public one-day ping archive, 19–20 July 2020, ${WS.length} servers. Statistic: median of each pair's per-run minimum. Servers inconsistent even with the globe have incorrect listed locations and were excluded: ${D.wn.dropped.map(esc).join(", ")}. ${D.network.wn_gap.length===1?"One pair":D.network.wn_gap.length+" pairs"} faster than the globe's own cable route, indicating a cable absent from the list, ${D.network.wn_gap.length===1?"is":"are"} excluded from the cable-route test on every model.</div>
<div>Cables</div><div>TeleGeography Submarine Cable Map: routes, landing points and published lengths. ${CAB.length} in-service cables have a published length; ${D.cables.dropped.length} further cables list a length shorter than the globe distance between their own landing points and were excluded as data errors.</div>
<div>Crossings</div><div>RIPE Atlas anchoring-mesh traceroutes (public), 15-minute interval, 24 hours.</div>
<div>Verification</div><div>GeoNames cities with populations above 150,000 (30,000 in Oceania); RIPE Atlas anchors as targets.</div>`;
const sl=(m,v)=>LP.filter(p=>p.T<floorMs(Math.max(0,dist(m,LS[p.a],LS[p.b])-25),v)).length;
document.getElementById("m3").innerHTML=`
<div>Straight lines</div><div>Live routes inconsistent when straight lines replace cable routes (the shortest possible cable): `+MODELS.map(([m,n])=>`${n} <b class="num">${sl(m,CF)}</b>`).join(" · ")+`.</div>
<div>Light in vacuum</div><div>Straight lines at 299,792 km/s, which no cable, radio link or satellite can exceed: `+MODELS.map(([m,n])=>`${n} <b class="num">${sl(m,CV)}</b>`).join(" · ")+`.</div>
<div>Model parameters</div><div>Re-centring the dual-pole AE model does not reduce the number of inconsistent WonderNetwork pairs below ${fint(dp[1])} (best centre ${dp[0]}°). Moving the Mercator boundary does not reduce it below ${fint(me[1])} (best boundary ${me[0]}°). Straight lines.</div>
<div>Limitations</div><div>Routes running mainly north–south, such as Seoul ↔ Sydney, have similar lengths on the globe and on North-pole AE and therefore do not discriminate between them.</div>
<div>Shape without a map</div><div>Fitted without any positions, the WonderNetwork round trips fit a plane as well as a sphere (Sammon stress ${D.pingmap.plane.toFixed(4)} and ${D.pingmap.sphere.toFixed(4)}): routes detour through hubs, and latency bounds distance only from above. Latency can therefore exclude a specific map but cannot by itself reconstruct the shape of the Earth; every test on this page compares measurements with a stated map. Code: ${lnk(SRCV+"src/pingmap.py","pingmap.py")}.</div>
<div>Cable identity</div><div>${XS.filter(t=>t.assumed).map(t=>`${esc(t.src_city)} → ${esc(t.dst_city)}`).join(", ")}: ${esc((XS.find(t=>t.assumed)||{}).assumed||"")} The verdict does not depend on it: the straight line on North-pole AE alone is ${fint(dist("ae",LS[LS.findIndex(x=>x.name==="Auckland")],LS[LS.findIndex(x=>x.name==="Sydney")]))} km, which light in vacuum covers in ${fmt(floorMs(dist("ae",LS[LS.findIndex(x=>x.name==="Auckland")],LS[LS.findIndex(x=>x.name==="Sydney")]),CV))} ms round trip.</div>`;
let h=`<thead><tr><th>Route</th><th>Measured, ms</th><th>Raw record</th>${MODELS.map(([m,n])=>`<th>${n} floor, ms<br>cable · straight</th>`).join("")}</tr></thead><tbody>`;
for(const p of LP.slice().sort((x,y)=>(LS[x.a].name+LS[x.b].name).localeCompare(LS[y.a].name+LS[y.b].name)))
h+=`<tr><td>${esc(LS[p.a].name)} ↔ ${esc(LS[p.b].name)}</td><td class="num">${fmt(p.T)}</td><td>${rawRef(p.cells[0])}</td>${MODELS.map(([m])=>{const c=rt(p,m,"cable"),l=rt(p,m,"line");return`<td class="num"><span class="${c.tier?TCLS[c.tier]:""}">${fmt(c.fl)}</span> · <span class="${l.tier?TCLS[l.tier]:""}">${fmt(l.fl)}</span></td>`}).join("")}</tr>`;
document.getElementById("grid").innerHTML=h+"</tbody>";
}
/* ---------- boot ---------- */
function layoutAll(){resize();fitKm();drawSM();renderEx();renderFlat();drawSpeed();frame();viewBtns()}
let rszT=0;window.addEventListener("resize",()=>{clearTimeout(rszT);rszT=setTimeout(layoutAll,120)});
document.querySelectorAll(".models button").forEach(b=>b.classList.toggle("on",b.dataset.v===S.view));
const ia=LS.findIndex(s=>s.name==="Sydney"),ib=LS.findIndex(s=>s.name==="Auckland");
S.sel=LP.find(p=>(p.a===ia&&p.b===ib)||(p.a===ib&&p.b===ia)).idx;
document.querySelectorAll("#mode button").forEach(b=>b.classList.toggle("on",b.dataset.mode===S.mode));
document.getElementById("srcsel").value=S.src;
{document.getElementById("eyeb").textContent=`${LS.length} sites · ${Object.keys(D.netlines).length} cables · 4 models`;
document.getElementById("step1").textContent=`Globalping probes, rented Vultr servers and a residential connection measured round-trip times to RIPE Atlas anchors and to each other across ${LS.length} sites. The minimum for each pair is used.`}
legend();renderScore();renderLadder();buildSM();renderX();renderNet();byoInit();renderMethod();renderCode();
readout();renderCard();resize();renderEx();renderFlat();drawSM();drawSpeed();kick();frame();
document.fonts&&document.fonts.ready.then(()=>{resize();drawSM();renderEx();renderFlat();drawSpeed();frame();viewBtns()});
</script>