68b9c3cf05
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
828 lines
62 KiB
HTML
828 lines
62 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>The Best Flat Map</title>
|
||
<meta name="description" content="{{N_CITIES}} cities, {{N_FLIGHTS}} timed flights and {{N_LIMITS}} measured limits laid out on a plane by a solver that keeps as many measurements as it can. The plane cannot close the southern routes; released into 3D, the same data closes into a ball.">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="The Best Flat Map">
|
||
<meta property="og:description" content="Every measured flight, ping, rail line and satellite pass on one plane. Best of {{N_STARTS}} starts: {{WORST_PAIR}} would need its aircraft at {{WORST_X}}× its top speed.">
|
||
<meta property="og:url" content="https://fe-map.aethercosmology.com/">
|
||
<meta property="og:image" content="https://fe-map.aethercosmology.com/og.png">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='3' y='9' width='26' height='14' rx='2' fill='%23081120' stroke='%233ee6ff' stroke-width='2'/%3E%3Cpath d='M8 19 L16 13 L24 18' fill='none' stroke='%23ff4d5e' stroke-width='2'/%3E%3C/svg%3E">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
|
||
<style>
|
||
/* Layout: control bar, then the plane (map left, readouts right on wide screens), then the 3D release, then results and method in a ~74ch column. */
|
||
:root{
|
||
--bg:#02050c; --panel:#07101e; --panel2:#0b182b; --line:rgba(120,180,255,.14); --line2:rgba(120,180,255,.28);
|
||
--ink:#e8f0fb; --dim:#9badc6; --faint:#65789a;
|
||
--land:#16263d; --coast:#3a5a80; --city:#ffffff; --track:#3ee6ff; --ok:#3dffa0; --warn:#ffc94a; --bad:#ff4d5e;
|
||
--disp:"Space Grotesk","Inter",system-ui,sans-serif;
|
||
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||
--mono:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
|
||
color-scheme:dark;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html{background:var(--bg)}
|
||
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);-webkit-font-smoothing:antialiased;
|
||
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
|
||
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:20;background:var(--bg)}
|
||
a{color:var(--track);text-decoration-color:rgba(62,230,255,.35);text-underline-offset:2px}
|
||
a:hover{text-decoration-color:var(--track)}
|
||
:focus-visible{outline:2px solid var(--track);outline-offset:2px}
|
||
.wrap{max-width:1720px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
|
||
.top{padding-block:28px 6px}
|
||
h1{font:700 clamp(28px,3.2vw,40px)/1.1 var(--disp);margin:0;letter-spacing:-.01em;text-wrap:balance}
|
||
.sub{margin:6px 0 0;color:var(--dim);font-size:15px}
|
||
.intro{display:grid;gap:8px 36px;padding-block:12px 14px}
|
||
@media (min-width:1100px){.intro{grid-template-columns:repeat(2,minmax(0,64ch))}}
|
||
.intro p{margin:0;max-width:74ch}
|
||
.intro b{font-family:var(--disp);font-weight:600;color:var(--dim);letter-spacing:.01em}
|
||
.k-bad{color:var(--bad);font-weight:600}.k-warn{color:var(--warn);font-weight:600}
|
||
|
||
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:rgba(2,5,12,.92);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-block:1px solid var(--line)}
|
||
@media (max-width:620px){.bar{position:static}}
|
||
.barin{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding-block:9px}
|
||
.row2{padding-block:0 9px;gap:8px 22px}
|
||
.rowlab{font:500 10.5px var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
|
||
.grp{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;font-size:12.5px;color:var(--dim)}
|
||
button{font:inherit;color:inherit}
|
||
.seg{display:flex;border:1px solid var(--line2);border-radius:8px;overflow:hidden}
|
||
.seg button{border:0;background:transparent;padding:6px 10px;font:500 12.5px var(--sans);color:var(--dim);cursor:pointer;white-space:nowrap}
|
||
.seg button+button{border-left:1px solid var(--line)}
|
||
.seg button[aria-pressed="true"]{background:var(--panel2);color:var(--ink)}
|
||
@media (max-width:620px){#starts{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line);width:100%}
|
||
#starts button{background:var(--bg);border-left:0}#starts button[aria-pressed="true"]{background:var(--panel2)}}
|
||
.btn{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 11px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink);white-space:nowrap}
|
||
.btn.primary{border-color:rgba(62,230,255,.55)}
|
||
.btn:disabled{opacity:.45;cursor:default}
|
||
.chk{display:flex;align-items:center;gap:5px;cursor:pointer;white-space:nowrap}
|
||
.chk input{accent-color:var(--track);margin:0}
|
||
.chk span{font-variant-numeric:tabular-nums;color:var(--faint)}
|
||
.rng{display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||
.rng input{width:96px;accent-color:var(--track)}
|
||
.rng output{font:500 12.5px var(--mono);color:var(--ink);min-width:3.2em}
|
||
#status{font:500 12.5px var(--mono);color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
#status b{color:var(--ink);font-weight:500}
|
||
|
||
.main{display:grid;gap:14px;padding-block:14px 8px;grid-template-columns:minmax(0,1fr)}
|
||
@media (min-width:1100px){.main{grid-template-columns:minmax(0,1fr) 380px}}
|
||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;min-width:0}
|
||
.mapbox{position:relative;width:100%;aspect-ratio:16/10;max-height:78vh;min-height:320px}
|
||
@media (max-width:620px){.mapbox{aspect-ratio:1/1.05}}
|
||
.mapbox canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;border-radius:10px}
|
||
.maphint{position:absolute;left:10px;bottom:8px;font-size:11.5px;color:var(--faint);pointer-events:none}
|
||
.mapcap{margin:0;padding:10px 12px 0;font-size:13.5px;line-height:1.5;color:var(--dim);border-top:1px solid var(--line)}
|
||
.mapcap b{color:var(--ink);font-weight:600}
|
||
.mapcap .k-ok{color:var(--ok)}
|
||
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;padding:10px 12px 12px;list-style:none;color:var(--dim);font-size:12.5px;border-top:1px solid var(--line)}
|
||
.legend li{display:flex;align-items:center;gap:7px}
|
||
.legend svg{width:24px;height:12px;flex:none}
|
||
|
||
.side{display:flex;flex-direction:column;gap:12px;padding:12px}
|
||
.side h2{font:600 15px/1.25 var(--disp);margin:0}
|
||
.score{width:100%;border-collapse:collapse;font-size:13px}
|
||
.score th,.score td{padding:5px 4px;border-bottom:1px solid var(--line);vertical-align:top}
|
||
.score tr:last-child td,.score tr:last-child th{border-bottom:0}
|
||
.score thead th{font:600 11px var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--faint);text-align:right}
|
||
.score thead th:first-child{text-align:left}
|
||
.score tbody th{text-align:left;font-weight:400;color:var(--dim)}
|
||
.score td{text-align:right;font:500 13px var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
.score td.bad{color:var(--bad)}.score td.warn{color:var(--warn)}.score td.ok{color:var(--ok)}
|
||
.score .of{color:var(--faint);font-size:11.5px}
|
||
.hist{display:flex;flex-direction:column;gap:4px}
|
||
.hist span{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
|
||
.hist canvas{width:100%;height:140px;display:block}
|
||
.worst{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px;font-size:12.5px}
|
||
.worst li{display:flex;justify-content:space-between;gap:10px;color:var(--dim);cursor:pointer}
|
||
.worst li b{color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.worst li span{font:500 12.5px var(--mono);white-space:nowrap}
|
||
.note{margin:0;font-size:12.5px;color:var(--faint)}
|
||
|
||
.lift{display:grid;gap:14px;padding-block:20px 8px;grid-template-columns:minmax(0,1fr)}
|
||
@media (min-width:1100px){.lift{grid-template-columns:minmax(0,1fr) 380px}}
|
||
.lift h2,.after h2{font:600 19px/1.25 var(--disp);margin:0 0 4px}
|
||
.box3{position:relative;width:100%;aspect-ratio:16/10;max-height:72vh;min-height:300px}
|
||
@media (max-width:620px){.box3{aspect-ratio:1/1}}
|
||
.box3 canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;border-radius:10px}
|
||
.ro{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px;margin:0}
|
||
.ro div{min-width:0}
|
||
.ro dt{font:500 10.5px/1.3 var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
|
||
.ro dd{margin:1px 0 0;font:500 14px/1.35 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
|
||
.after{padding-block:22px 10px;display:grid;gap:10px}
|
||
.after h3{font:600 15.5px/1.3 var(--disp);margin:6px 0 0}
|
||
.after p{margin:0;max-width:74ch;color:var(--dim)}
|
||
.after p b{color:var(--ink);font-weight:600}
|
||
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px;max-width:980px}
|
||
table.res{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
|
||
.res th,.res td{padding:8px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
|
||
.res tr:last-child td,.res tr:last-child th{border-bottom:0}
|
||
.res thead th{font:600 12.5px var(--disp);color:var(--dim);background:var(--panel)}
|
||
.res tbody th{text-align:left;font-weight:500;color:var(--dim)}
|
||
.res td{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||
.method{display:grid;grid-template-columns:max-content minmax(0,74ch);gap:8px 20px;margin:0;color:var(--dim)}
|
||
.method dt{font:600 13.5px var(--disp);color:var(--ink);padding-top:1px}
|
||
.method dd{margin:0}
|
||
@media (max-width:620px){.method{grid-template-columns:minmax(0,1fr);gap:2px}.method dd{margin-bottom:10px}}
|
||
#dl{justify-self:start}
|
||
footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
|
||
#tip{position:fixed;z-index:30;pointer-events:none;background:rgba(6,12,24,.97);border:1px solid var(--line2);border-radius:8px;padding:7px 10px;font-size:12.5px;line-height:1.45;max-width:300px}
|
||
#tip b{font-weight:600}
|
||
#tip span{display:block;color:var(--dim)}
|
||
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="wrap top">
|
||
<h1>The Best Flat Map</h1>
|
||
<p class="sub">Every measured flight, ping, rail line and satellite pass on one plane</p>
|
||
</header>
|
||
|
||
<section class="wrap intro">
|
||
<p><b>Rule.</b> The surface is a plane. Each of the {{N_CITIES}} cities may sit anywhere on it. No latitude, longitude, radius, projection or spherical formula enters the solver.</p>
|
||
<p><b>Measurements.</b> Flight times give lengths: {{N_FLIGHTS}} routes, cruise speed × time in the air, both directions. Pings ({{N_RT}}), rail and road lengths ({{N_RAIL}}) and satellite passes ({{N_SAT}}) give limits: no farther apart than this.</p>
|
||
<p><b>Tolerance.</b> A flight holds between {{LO_PCT}}% and {{HI_PCT}}% of its length. Shorter means a route over a third longer than the straight line; longer means an aircraft above its top speed the whole way.</p>
|
||
<p><b>Solver.</b> The page opens on a starting flat map as drawn. <i>Solve</i> moves the cities to keep as many measurements as it can and marks the rest <span class="k-bad">red</span> (faster than possible) or <span class="k-warn">amber</span> (far off the straight line). Coastlines follow the cities like a rubber sheet.</p>
|
||
</section>
|
||
|
||
<div class="bar">
|
||
<div class="wrap barin">
|
||
<span class="rowlab">Start from</span>
|
||
<div class="seg" role="group" aria-label="Starting map" id="starts">
|
||
<button type="button" data-start="ae" aria-pressed="true" title="Azimuthal equidistant map centred on the North Pole">North-pole AE</button>
|
||
<button type="button" data-start="dp" aria-pressed="false" title="Azimuthal equidistant map centred on 0° N 0° E">Dual-pole AE</button>
|
||
<button type="button" data-start="mds" aria-pressed="false" title="No existing map: the cities laid out from the flight times alone">No map</button>
|
||
<button type="button" data-start="best" aria-pressed="false" title="The best layout found offline from {{N_STARTS}} starting maps">Best of {{N_STARTS}}</button>
|
||
</div>
|
||
<button type="button" class="btn primary" id="run">Pause</button>
|
||
<button type="button" class="btn" id="reset">Restart</button>
|
||
<span id="status"></span>
|
||
</div>
|
||
<div class="wrap barin row2">
|
||
<span class="rowlab">Data</span>
|
||
<div class="grp">
|
||
<label class="chk"><input type="checkbox" id="d-fl" checked> Flight times <span>{{N_FLIGHTS}}</span></label>
|
||
<label class="chk"><input type="checkbox" id="d-rt" checked> Pings <span>{{N_RT}}</span></label>
|
||
<label class="chk"><input type="checkbox" id="d-rr" checked> Rail & road <span>{{N_RAIL}}</span></label>
|
||
<label class="chk"><input type="checkbox" id="d-sat" checked> Satellite passes <span>{{N_SAT}}</span></label>
|
||
</div>
|
||
<div class="grp">
|
||
<label class="rng" for="lo">Flight distance from <input type="range" id="lo" min="40" max="95" step="5" value="{{LO_PCT}}"><output id="lov">{{LO_PCT}}%</output></label>
|
||
<label class="rng" for="hi">to <input type="range" id="hi" min="100" max="150" step="5" value="{{HI_PCT}}"><output id="hiv">{{HI_PCT}}%</output></label>
|
||
</div>
|
||
<div class="seg" role="group" aria-label="Signal speed for pings" id="light">
|
||
<button type="button" data-v="glass" aria-pressed="true" title="Pings travel at the speed of light in optical fibre">Pings at fibre speed</button>
|
||
<button type="button" data-v="vacuum" aria-pressed="false" title="Pings travel at the speed of light in vacuum (hollow-core fibre or better)">Pings at vacuum speed</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="wrap main" aria-label="The plane">
|
||
<div class="panel">
|
||
<div class="mapbox"><canvas id="map" aria-label="The flat map the solver is building"></canvas><span class="maphint">Drag to pan · scroll to zoom · click a city</span></div>
|
||
<p class="mapcap" id="mapcap" aria-live="polite"></p>
|
||
<ul class="legend">
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2"/></svg>Flight longer than its aircraft can fly</li>
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ffc94a" stroke-width="2"/></svg>Flight squeezed short</li>
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2" stroke-dasharray="4 3"/></svg>Limit broken (signal faster than light, line longer than its track)</li>
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#3dffa0" stroke-width="1.5"/></svg>Kept (selected city)</li>
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><circle cx="3" cy="6" r="2" fill="none" stroke="#8fa7c4"/><line x1="5" y1="6" x2="20" y2="6" stroke="#8fa7c4" stroke-width="1"/><circle cx="21" cy="6" r="2" fill="#fff"/></svg>A city's move from the starting map</li>
|
||
<li><svg viewBox="0 0 24 12" aria-hidden="true"><rect x="1" y="1" width="10" height="10" fill="#16263d"/><rect x="13" y="1" width="10" height="10" fill="#16263d" fill-opacity=".35"/></svg>Land near a city · land with no city nearby</li>
|
||
</ul>
|
||
</div>
|
||
<aside class="panel side" aria-label="Score">
|
||
<h2>Broken measurements</h2>
|
||
<table class="score">
|
||
<thead><tr><th scope="col"></th><th scope="col">This plane</th><th scope="col">Globe</th></tr></thead>
|
||
<tbody id="score"></tbody>
|
||
</table>
|
||
<div class="hist"><span>Map distance ÷ timed length, every flight</span><canvas id="hist" aria-label="Histogram of map distance divided by timed length"></canvas></div>
|
||
<div><h2 style="margin-bottom:6px">Farthest out</h2><ul class="worst" id="worst"></ul></div>
|
||
<p class="note" id="flnote" hidden>Flight times are off, so every remaining measurement is a maximum distance. Any flat map drawn small enough meets them all, so a score of 0 here says nothing about the shape. The triangles do not depend on the layout.</p>
|
||
<p class="note" id="globenote">Globe: the same rules applied to great-circle distances on a sphere of radius 6,371 km.</p>
|
||
</aside>
|
||
</section>
|
||
|
||
<section class="wrap lift" aria-label="Without the plane">
|
||
<div class="panel">
|
||
<div class="box3"><canvas id="c3" aria-label="The same measurements laid out in free 3D space"></canvas><span class="maphint">Drag to turn</span></div>
|
||
</div>
|
||
<aside class="panel side">
|
||
<h2>Lift the rule</h2>
|
||
<p class="note" style="color:var(--dim)">Same cities, same measurements, same solver. The points may now leave the plane; nothing tells them what shape to take. It starts from the flat map above with each city nudged a few kilometres up or down.</p>
|
||
<div class="grp"><button type="button" class="btn primary" id="lift">Release into 3D</button><button type="button" class="btn" id="spin" aria-pressed="true">Turning</button></div>
|
||
<dl class="ro">
|
||
<div><dt>Broken</dt><dd id="r3b">–</dd></div>
|
||
<div><dt>Stage</dt><dd id="r3s">–</dd></div>
|
||
<div><dt>Wraps round its centre</dt><dd id="r3w">–</dd></div>
|
||
<div><dt>Best-fit sphere radius</dt><dd id="r3r">–</dd></div>
|
||
<div><dt>Median distance off it</dt><dd id="r3o">–</dd></div>
|
||
<div><dt>Broken on the plane</dt><dd id="r3f">–</dd></div>
|
||
</dl>
|
||
<p class="note">The sphere is fitted after the solve to describe the shape and plays no part in it. Lengths may land anywhere inside their tolerance, so its radius is only loosely set.</p>
|
||
</aside>
|
||
</section>
|
||
|
||
<section class="wrap after">
|
||
<h2>Result</h2>
|
||
<p>Best of {{N_STARTS}} starting maps (North-pole and dual-pole AE, the layout of the flight times alone, and AE maps centred on {{N_STARTS_AE}} other points): <b>{{BEST_BROKEN}} measurements broken</b>. The plane keeps almost the whole northern network, but it cannot close the ring of routes around the southern oceans: {{LONG_LIST}}. Each of those needs its aircraft faster than it can fly. To keep the rest, the plane squeezes {{BEST_B80}} flights below 80% of their timed length; on the globe {{GLOBE_B80}} are.</p>
|
||
<p>On the globe, by the same rules: {{GLOBE_LONG}} flight longer than its aircraft can fly (San Salvador–San José, a short hop where the 15-minute allowance for climb and descent is too generous), {{GLOBE_LIM}} limits broken, and {{GLOBE_SHORT}} flights below {{LO_PCT}}%: detours around closed airspace, indirect airways, and short hops where climb and approach take most of the time ({{GLOBE_SHORT_LIST}}). Surveyed triangles do not depend on the layout: a plane gives every triangle exactly 180°, and {{TRI_FLAT}} of the {{N_TRI}} measured triangles exceed that by more than four times their survey's angle noise ({{TRI_GLOBE}} on the globe; <a href="https://distance.aethercosmology.com/#triangles">The Triangle Test</a>).</p>
|
||
<p>Released into 3D, the same cities and measurements close into a ball. The cities wrap {{WRAP3}}° round its centre, with {{BROKEN3}} measurements broken.</p>
|
||
<h3>Wider tolerances</h3>
|
||
<p>The further short a flight may fall, the more of the network the plane can squeeze to make room for the southern routes. It keeps everything once flights may fall to {{FIT_LO}}% of their timed length, which means routes up to twice as long as its straight lines. That layout has {{FIT_B75}} flights below 75% and {{FIT_B60}} below 60%. The globe has {{GLOBE_B75}} and {{GLOBE_B60}}.</p>
|
||
<div class="tw"><table class="res" id="bands"><thead><tr><th scope="col">Flights kept between</th><th scope="col">Plane: too long</th><th scope="col">Plane: too short</th><th scope="col">Plane: limits</th><th scope="col">Plane: below 75%</th><th scope="col">Globe: too long</th><th scope="col">Globe: too short</th><th scope="col">Globe: limits</th></tr></thead><tbody></tbody></table></div>
|
||
<h3>Pings alone</h3>
|
||
<p>A ping only caps a distance, and internet routes run well past the straight line, so the caps are loose. With pings alone a flat map can shrink until every cap holds; turn off Flight times to see it. Scale comes from lengths, and the flights supply them.</p>
|
||
|
||
<h2 style="margin-top:14px">Method</h2>
|
||
<dl class="method">
|
||
<dt>Flights</dt><dd>From <a href="https://distance.aethercosmology.com/#sphere">The Measured Sphere</a> ("each flight's own speed"): routes flown both ways, each direction's median time from wheels-off to wheels-on over two weeks (FlightAware), less 15 minutes for climb and descent, times the aircraft type's cruise speed (Mach × 1,062 km/h). Averaging the two directions cancels the wind. Click a city for each route's flights, aircraft and track logs.</dd>
|
||
<dt>Limits</dt><dd>A ping is a round trip: the signal goes out and comes back. A ping of <i>t</i> ms caps a distance at {{KM_PER_MS}} km per ms (light in fibre; the vacuum option multiplies by 1.468), plus 25 km at each end and the distance from each server to its city's airport. Sources: WonderNetwork's 2020 archive and the live RIPE Atlas, Globalping and Vultr measurements on <a href="https://pingstats.aethercosmology.com/">pingstats</a>. {{N_DROPPED}} pings shorter than every surface allows were left out as mislocated servers. Rail and road: the straight line can be no longer than the published route. Satellite passes: two stations can be no farther apart than the two closest ranges plus the satellite's measured flight between them.</dd>
|
||
<dt>Solver</dt><dd>First a least-squares fit of every flight's relative error. Then eight rounds of a reweighted, nearly linear penalty on how far each measurement falls outside its band, which gives up on as few measurements as it can instead of straining many (the layout with the fewest broken is kept). Limits count three times a flight. L-BFGS throughout. The page runs the same steps live. Offline, the same code (<code>fit.py</code>) ran from {{N_STARTS}} starting maps and then refined the 20 best layouts with twelve rounds.</dd>
|
||
<dt>Drawing</dt><dd>Coastlines (Natural Earth 1:110m) are projected on the starting map, then carried along by a thin-plate spline through the cities' starting and current positions. Land near the starting map's rim is outlined only. Land more than 900 km from every city is drawn faint, and beyond 1,500 km fainter still: no measurement fixes its shape. Each city's move from the starting map is drawn as a thin line.</dd>
|
||
<dt>3D</dt><dd>The same objective with a third coordinate. After the solve, a least-squares sphere is fitted to the points to describe the shape; how far round it reaches is the angle from the cities' mean direction to the farthest city.</dd>
|
||
</dl>
|
||
<button type="button" class="btn" id="dl">Download data (JSON)</button>
|
||
</section>
|
||
|
||
<footer class="wrap">Built {{BUILT}}. <a href="https://git.aethercosmology.com/toby/cable-latency-survey">Code and data</a> (MIT, CC BY 4.0) · <a href="https://git.aethercosmology.com/toby/cable-latency-survey/src/branch/main/CONTRIBUTING.md">Contribute</a> · Part of <a href="https://distance.aethercosmology.com/">The Shape of Distance</a>.</footer>
|
||
|
||
<div id="tip" hidden></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
const D = /*DATA*/null;
|
||
const N = D.cities.length, R = 6371.0088, D2R = Math.PI / 180;
|
||
const $ = s => document.querySelector(s);
|
||
const fmt = n => Math.round(n).toLocaleString("en-US");
|
||
const css = getComputedStyle(document.documentElement);
|
||
const COL = { bad: css.getPropertyValue("--bad").trim(), warn: css.getPropertyValue("--warn").trim(), ok: css.getPropertyValue("--ok").trim(),
|
||
land: css.getPropertyValue("--land").trim(), coast: css.getPropertyValue("--coast").trim(), ink: css.getPropertyValue("--ink").trim(),
|
||
dim: css.getPropertyValue("--dim").trim(), faint: css.getPropertyValue("--faint").trim(), track: css.getPropertyValue("--track").trim() };
|
||
|
||
/* ---------------- measurements ---------------- */
|
||
const FL = D.fl.map((f, i) => ({ i, a: f[0], b: f[1], L: f[2], airline: f[3], fa: f[4], fb: f[5], page: f[6], ab: f[7], ba: f[8], g: D.gf[i] }));
|
||
const LIM = D.lim.map((l, i) => ({ i, a: l[0], b: l[1], U: l[2], kind: l[3], note: l[4], label: l[5], url: l[6], g: D.gl[i] }));
|
||
const KIND = { rt: "Ping", rail: "Rail", road: "Road", sat: "Satellite pass" };
|
||
const cityName = i => D.cities[i][0];
|
||
|
||
const S = { fl: true, rt: true, rr: true, sat: true, lo: D.stats.lo, hi: D.stats.hi, vac: false, start: "ae" };
|
||
let act = null; // active measurement arrays
|
||
function buildActive() {
|
||
const f = S.fl ? FL : [];
|
||
const l = LIM.filter(x => (x.kind === "rt" && S.rt) || ((x.kind === "rail" || x.kind === "road") && S.rr) || (x.kind === "sat" && S.sat));
|
||
act = { f, l, fa: Int32Array.from(f, x => x.a), fb: Int32Array.from(f, x => x.b), fL: Float64Array.from(f, x => x.L),
|
||
la: Int32Array.from(l, x => x.a), lb: Int32Array.from(l, x => x.b), lU: Float64Array.from(l, x => x.kind === "rt" && S.vac ? x.U * 1.4682 : x.U) };
|
||
}
|
||
|
||
/* ---------------- solver (mirrors fit.py) ---------------- */
|
||
const LIMIT_W = 3, MARGIN = 0.005, DELTA = 0.003, EPS = 0.02, ROUNDS = 8;
|
||
function makeSolver(dim, X0) {
|
||
const n = N * dim;
|
||
const sv = { dim, X: Float64Array.from(X0), stage: 0, it: 0, total: 0, w: new Float64Array(act.f.length).fill(1),
|
||
wl: new Float64Array(act.l.length).fill(LIMIT_W), mem: [], g: null, F: null, done: false, best: null, bestK: Infinity, hist: [], acc: 0 };
|
||
sv.evalF = (X, G) => {
|
||
G.fill(0); let F = 0;
|
||
const { fa, fb, fL, la, lb, lU } = act; const lo = S.lo, hi = S.hi;
|
||
for (let k = 0; k < fa.length; k++) {
|
||
const a = fa[k] * dim, b = fb[k] * dim; let dd = 1e-9;
|
||
for (let q = 0; q < dim; q++) { const t = X[a + q] - X[b + q]; dd += t * t; }
|
||
dd = Math.sqrt(dd); let ga;
|
||
if (sv.stage === 0) { const e = dd / fL[k] - 1; F += e * e; ga = 2 * e / fL[k] / dd; }
|
||
else {
|
||
const A = fL[k] * (1 - lo + MARGIN), B = fL[k] * (1 + hi - MARGIN);
|
||
const eh = Math.max(0, dd / B - 1), el = Math.max(0, 1 - dd / A);
|
||
const sh = Math.sqrt(eh * eh + DELTA * DELTA), sl = Math.sqrt(el * el + DELTA * DELTA);
|
||
F += sv.w[k] * (sh + sl - 2 * DELTA); ga = sv.w[k] * (eh / sh / B - el / sl / A) / dd;
|
||
}
|
||
for (let q = 0; q < dim; q++) { const t = ga * (X[a + q] - X[b + q]); G[a + q] += t; G[b + q] -= t; }
|
||
}
|
||
for (let k = 0; k < la.length; k++) {
|
||
const a = la[k] * dim, b = lb[k] * dim; let dd = 1e-9;
|
||
for (let q = 0; q < dim; q++) { const t = X[a + q] - X[b + q]; dd += t * t; }
|
||
dd = Math.sqrt(dd); let gl;
|
||
if (sv.stage === 0) { const ex = Math.max(0, dd / lU[k] - 1); if (!ex) continue; F += 100 * ex * ex; gl = 200 * ex / lU[k] / dd; }
|
||
else {
|
||
const ub = lU[k] * (1 - MARGIN), ex = Math.max(0, dd / ub - 1); if (!ex) continue;
|
||
const sx = Math.sqrt(ex * ex + DELTA * DELTA); F += sv.wl[k] * (sx - DELTA); gl = sv.wl[k] * ex / sx / ub / dd;
|
||
}
|
||
for (let q = 0; q < dim; q++) { const t = gl * (X[a + q] - X[b + q]); G[a + q] += t; G[b + q] -= t; }
|
||
}
|
||
return F;
|
||
};
|
||
sv.G = new Float64Array(n); sv.F = sv.evalF(sv.X, sv.G);
|
||
sv.Xn = new Float64Array(n); sv.Gn = new Float64Array(n); sv.d = new Float64Array(n);
|
||
sv.stall = 0;
|
||
return sv;
|
||
}
|
||
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
||
function lbfgsStep(sv) {
|
||
const { X, G, d, Xn, Gn, mem } = sv; const n = X.length;
|
||
for (let i = 0; i < n; i++) d[i] = -G[i];
|
||
const al = [];
|
||
for (let j = mem.length - 1; j >= 0; j--) { const m = mem[j]; const a = m.rho * dot(m.s, d); al[j] = a; for (let i = 0; i < n; i++) d[i] -= a * m.y[i]; }
|
||
let gam;
|
||
if (mem.length) { const m = mem[mem.length - 1]; gam = dot(m.s, m.y) / dot(m.y, m.y); }
|
||
else { let gm = 0; for (let i = 0; i < n; i++) gm = Math.max(gm, Math.abs(G[i])); gam = gm > 0 ? 200 / gm : 1; }
|
||
for (let i = 0; i < n; i++) d[i] *= gam;
|
||
for (let j = 0; j < mem.length; j++) { const m = mem[j]; const b = m.rho * dot(m.y, d); for (let i = 0; i < n; i++) d[i] += m.s[i] * (al[j] - b); }
|
||
let gd = dot(G, d);
|
||
if (!(gd < 0)) { mem.length = 0; for (let i = 0; i < n; i++) d[i] = -G[i] * gam; gd = dot(G, d); if (!(gd < 0)) return false; }
|
||
let t = 1, Fn = 0;
|
||
for (let k = 0; k < 40; k++) {
|
||
for (let i = 0; i < n; i++) Xn[i] = X[i] + t * d[i];
|
||
Fn = sv.evalF(Xn, Gn);
|
||
if (Fn <= sv.F + 1e-4 * t * gd) break;
|
||
t *= 0.5; if (k === 39) return false;
|
||
}
|
||
const s = new Float64Array(n), y = new Float64Array(n);
|
||
for (let i = 0; i < n; i++) { s[i] = Xn[i] - X[i]; y[i] = Gn[i] - G[i]; }
|
||
const sy = dot(s, y);
|
||
if (sy > 1e-12) { mem.push({ s, y, rho: 1 / sy }); if (mem.length > 12) mem.shift(); }
|
||
const dec = sv.F - Fn;
|
||
X.set(Xn); G.set(Gn); sv.F = Fn; sv.it++; sv.total++;
|
||
sv.stall = dec <= 1e-9 * Math.max(1e-12, Math.abs(Fn)) ? sv.stall + 1 : 0;
|
||
return true;
|
||
}
|
||
function excess(sv) {
|
||
const { dim, X } = sv; const e = new Float64Array(act.f.length), l = new Float64Array(act.l.length);
|
||
for (let k = 0; k < act.fa.length; k++) { const r = dist(X, dim, act.fa[k], act.fb[k]) / act.fL[k]; e[k] = Math.max(0, r / (1 + S.hi) - 1) + Math.max(0, 1 - r / (1 - S.lo)); }
|
||
for (let k = 0; k < act.la.length; k++) l[k] = Math.max(0, dist(X, dim, act.la[k], act.lb[k]) / act.lU[k] - 1);
|
||
return { e, l };
|
||
}
|
||
function nBroken(sv) { const { e, l } = excess(sv); let k = 0; for (const v of e) if (v > 0) k++; for (const v of l) if (v > 5e-4) k++; return k; }
|
||
function nextStage(sv) {
|
||
const k = nBroken(sv);
|
||
if (k < sv.bestK) { sv.bestK = k; sv.best = Float64Array.from(sv.X); }
|
||
if (sv.stage >= ROUNDS) { sv.done = true; if (sv.best) sv.X.set(sv.best); sv.F = sv.evalF(sv.X, sv.G); return; }
|
||
if (sv.stage > 0) { const { e, l } = excess(sv); for (let i = 0; i < e.length; i++) sv.w[i] = EPS / (e[i] + EPS); for (let i = 0; i < l.length; i++) sv.wl[i] = LIMIT_W * EPS / (l[i] + EPS); }
|
||
sv.stage++; sv.it = 0; sv.mem.length = 0; sv.stall = 0; sv.F = sv.evalF(sv.X, sv.G);
|
||
}
|
||
function work(sv, ms, maxIt) {
|
||
const t0 = performance.now(); let k = 0;
|
||
while (!sv.done && performance.now() - t0 < ms && k < maxIt) {
|
||
const ok = lbfgsStep(sv); k++;
|
||
if (!ok || sv.stall > 25 || sv.it > (sv.stage ? 2500 : 4000)) nextStage(sv);
|
||
}
|
||
}
|
||
function dist(X, dim, a, b) { let s = 0; for (let q = 0; q < dim; q++) { const t = X[a * dim + q] - X[b * dim + q]; s += t * t; } return Math.sqrt(s); }
|
||
const STAGE = s => s === 0 ? "fitting lengths" : `round ${s} of ${ROUNDS}`;
|
||
|
||
/* ---------------- maps and rubber-sheet coastlines ---------------- */
|
||
function aeq(lat, lon, lat0, lon0) {
|
||
const p1 = lat * D2R, l1 = lon * D2R, p0 = lat0 * D2R, l0 = lon0 * D2R;
|
||
const cc = Math.sin(p0) * Math.sin(p1) + Math.cos(p0) * Math.cos(p1) * Math.cos(l1 - l0);
|
||
const c = Math.acos(Math.max(-1, Math.min(1, cc))); const k = c < 1e-9 ? 1 : c / Math.sin(c);
|
||
return [R * k * Math.cos(p1) * Math.sin(l1 - l0), R * k * (Math.cos(p0) * Math.sin(p1) - Math.sin(p0) * Math.cos(p1) * Math.cos(l1 - l0)), c];
|
||
}
|
||
function invert(A, n) { // Gauss-Jordan, returns inverse of n x n (row-major Float64Array)
|
||
const M = new Float64Array(n * 2 * n);
|
||
for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) M[i * 2 * n + j] = A[i * n + j]; M[i * 2 * n + n + i] = 1; }
|
||
for (let c = 0; c < n; c++) {
|
||
let p = c; for (let r = c + 1; r < n; r++) if (Math.abs(M[r * 2 * n + c]) > Math.abs(M[p * 2 * n + c])) p = r;
|
||
if (p !== c) for (let j = 0; j < 2 * n; j++) { const t = M[c * 2 * n + j]; M[c * 2 * n + j] = M[p * 2 * n + j]; M[p * 2 * n + j] = t; }
|
||
const v = M[c * 2 * n + c]; for (let j = 0; j < 2 * n; j++) M[c * 2 * n + j] /= v;
|
||
for (let r = 0; r < n; r++) { if (r === c) continue; const f = M[r * 2 * n + c]; if (!f) continue; for (let j = 0; j < 2 * n; j++) M[r * 2 * n + j] -= f * M[c * 2 * n + j]; }
|
||
}
|
||
const out = new Float64Array(n * n); for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) out[i * n + j] = M[i * 2 * n + n + j]; return out;
|
||
}
|
||
const tpsU = r2 => r2 > 1e-12 ? 0.5 * r2 * Math.log(r2) : 0; // r^2 log r, in 1000-km units
|
||
let SHEET = null; // {src, Ainv, K, M, rings}
|
||
function buildSheet(centre, srcX) {
|
||
const n = N, m = n + 3, U = 1000;
|
||
const sx = new Float64Array(n), sy = new Float64Array(n);
|
||
for (let i = 0; i < n; i++) { sx[i] = srcX[i][0] / U; sy[i] = srcX[i][1] / U; }
|
||
const A = new Float64Array(m * m);
|
||
for (let i = 0; i < n; i++) {
|
||
for (let j = 0; j < n; j++) { const dx = sx[i] - sx[j], dy = sy[i] - sy[j]; A[i * m + j] = tpsU(dx * dx + dy * dy); }
|
||
A[i * m + i] += 1e-6;
|
||
A[i * m + n] = A[n * m + i] = 1; A[i * m + n + 1] = A[(n + 1) * m + i] = sx[i]; A[i * m + n + 2] = A[(n + 2) * m + i] = sy[i];
|
||
}
|
||
const Ainv = invert(A, m);
|
||
// coastline points in source coordinates; rings near the starting map's rim are outlined only
|
||
const pts = [], rings = [];
|
||
const anti = [-centre[0], centre[1] + 180]; // the starting map's rim: the point opposite its centre
|
||
for (const poly of D.land) for (const ring of poly) {
|
||
const idx = []; let wind = 0, prev = null;
|
||
for (const [lon, lat] of ring) {
|
||
const a = aeq(lat, lon, anti[0], anti[1]), t = Math.atan2(a[1], a[0]);
|
||
if (prev !== null) { let d = t - prev; if (d > Math.PI) d -= 2 * Math.PI; if (d < -Math.PI) d += 2 * Math.PI; wind += d; } prev = t;
|
||
const p = aeq(lat, lon, centre[0], centre[1]); if (p[2] > 168 * D2R) continue;
|
||
idx.push(pts.length / 2); pts.push(p[0] / U, p[1] / U);
|
||
}
|
||
// A coast that separates the centre from the rim point bounds either land around the centre (fill) or land
|
||
// around the rim point, which cannot be laid flat as one piece (outline). Land is the smaller side: measure
|
||
// the side holding the centre on an equal-area map.
|
||
let fill = true;
|
||
if (Math.abs(wind) >= Math.PI) {
|
||
let A = 0, q0 = null;
|
||
const p0 = centre[0] * D2R, l0 = centre[1] * D2R;
|
||
for (const [lon, lat] of ring) {
|
||
const p1 = lat * D2R, l1 = lon * D2R, cc = Math.sin(p0) * Math.sin(p1) + Math.cos(p0) * Math.cos(p1) * Math.cos(l1 - l0), k = Math.sqrt(2 / Math.max(1e-9, 1 + cc));
|
||
const q = [k * Math.cos(p1) * Math.sin(l1 - l0), k * (Math.cos(p0) * Math.sin(p1) - Math.sin(p0) * Math.cos(p1) * Math.cos(l1 - l0))];
|
||
if (q0) A += q0[0] * q[1] - q[0] * q0[1]; q0 = q;
|
||
}
|
||
fill = Math.abs(A / 2) < 2 * Math.PI; // unit sphere: half its area is 2 pi
|
||
}
|
||
if (idx.length > 2) rings.push({ idx, fill });
|
||
}
|
||
const M = pts.length / 2, K = new Float32Array(M * m), near = new Uint8Array(M);
|
||
for (let p = 0; p < M; p++) {
|
||
const x = pts[2 * p], y = pts[2 * p + 1];
|
||
let dmin = Infinity; for (let j = 0; j < n; j++) dmin = Math.min(dmin, (x - sx[j]) ** 2 + (y - sy[j]) ** 2);
|
||
near[p] = dmin < 1.6 * 1.6 ? 1 : 0; // within 1,600 km of a city on the starting map
|
||
for (let j = 0; j < n; j++) { const dx = x - sx[j], dy = y - sy[j]; K[p * m + j] = tpsU(dx * dx + dy * dy); }
|
||
K[p * m + n] = 1; K[p * m + n + 1] = x; K[p * m + n + 2] = y;
|
||
}
|
||
SHEET = { Ainv, K, M, m, rings, near, out2: new Float32Array(M * 2), out3: new Float32Array(M * 3) };
|
||
}
|
||
function warp(X, dim) { // thin-plate spline through (source -> X), evaluated at every coastline point
|
||
const { Ainv, K, M, m } = SHEET; const n = N; const W = new Float64Array(m * dim);
|
||
for (let r = 0; r < m; r++) for (let q = 0; q < dim; q++) { let s = 0; for (let j = 0; j < n; j++) s += Ainv[r * m + j] * X[j * dim + q]; W[r * dim + q] = s; }
|
||
const out = dim === 2 ? SHEET.out2 : SHEET.out3;
|
||
for (let p = 0; p < M; p++) for (let q = 0; q < dim; q++) { let s = 0; const o = p * m; for (let r = 0; r < m; r++) s += K[o + r] * W[r * dim + q]; out[p * dim + q] = s; }
|
||
return out;
|
||
}
|
||
|
||
/* ---------------- state ---------------- */
|
||
let sv = null, running = false, sel = -1, view = null, userView = false, coast = null, coastAt = -1, ORIG = null;
|
||
const SANTIAGO = D.cities.findIndex(c => c[0] === "Santiago");
|
||
const START_LABEL = { ae: "North-pole AE map", dp: "Dual-pole AE map", mds: "Flight-time layout" };
|
||
function startPositions(key) {
|
||
if (key === "best") return { centre: D.best.centre, X: D.best.X, label: D.best.label };
|
||
const s = D.starts[key]; return { centre: s.centre, X: s.X, label: s.label };
|
||
}
|
||
function restart() {
|
||
buildActive();
|
||
const st = startPositions(S.start);
|
||
// the drawing domain: the cities on the map the coastlines are projected from (AE on the start's centre;
|
||
// the flight-times-only start is drawn from North-pole AE)
|
||
const src = D.cities.map(c => aeq(c[1], c[2], st.centre[0], st.centre[1]));
|
||
if (!SHEET || SHEET.key !== S.start) { buildSheet(st.centre, src); SHEET.key = S.start; }
|
||
sv = makeSolver(2, st.X.flat()); sv.watch = true;
|
||
ORIG = Float64Array.from(S.start === "best" ? src.flatMap(p => [p[0], p[1]]) : st.X.flat());
|
||
sel = -1;
|
||
if (S.start === "best") { sv.done = true; sv.stage = ROUNDS; sv.isBest = true; sel = SANTIAGO; }
|
||
running = false; userView = false; view = null; coastAt = -1;
|
||
$("#run").textContent = "Solve";
|
||
update(true);
|
||
}
|
||
function continueSolve() { // data or tolerance changed: keep the current layout and solve again
|
||
if (!sv) return restart();
|
||
buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); sv.watch = false; running = true; $("#run").textContent = "Pause"; update(true);
|
||
}
|
||
|
||
/* ---------------- scoring ---------------- */
|
||
function score(X, dim) {
|
||
const out = { long: [], short: [], rt: [], rr: [], sat: [], ratio: new Float64Array(FL.length).fill(NaN), lim: new Map() };
|
||
if (S.fl) for (const f of FL) {
|
||
const r = dist(X, dim, f.a, f.b) / f.L; out.ratio[f.i] = r;
|
||
if (r > 1 + S.hi) out.long.push([f, r]); else if (r < 1 - S.lo) out.short.push([f, r]);
|
||
}
|
||
for (const l of act.l) {
|
||
const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; const r = dist(X, dim, l.a, l.b) / U;
|
||
if (r > 1.0005) { const k = l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"; out[k].push([l, r]); out.lim.set(l.i, r); }
|
||
}
|
||
return out;
|
||
}
|
||
function globeScore() {
|
||
const o = { long: 0, short: 0, rt: 0, rr: 0, sat: 0 };
|
||
if (S.fl) for (const f of FL) { const r = f.g / f.L; if (r > 1 + S.hi) o.long++; else if (r < 1 - S.lo) o.short++; }
|
||
for (const l of act.l) { const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; if (l.g > U * 1.0005) o[l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"]++; }
|
||
return o;
|
||
}
|
||
let SC = null, GS = null;
|
||
function update(full) {
|
||
SC = score(sv.X, 2); if (full) GS = globeScore();
|
||
$("#flnote").hidden = S.fl;
|
||
const nAct = { rt: act.l.filter(l => l.kind === "rt").length, rr: act.l.filter(l => l.kind === "rail" || l.kind === "road").length, sat: act.l.filter(l => l.kind === "sat").length };
|
||
const row = (label, a, b, n, cls) => `<tr><th scope="row">${label}</th><td class="${a ? cls : "ok"}">${fmt(a)}<span class="of"> / ${fmt(n)}</span></td><td class="${b ? cls : "ok"}">${fmt(b)}</td></tr>`;
|
||
const nf = S.fl ? FL.length : 0;
|
||
$("#score").innerHTML =
|
||
row("Flights longer than the aircraft can fly", SC.long.length, GS.long, nf, "bad") +
|
||
row(`Flights below ${Math.round((1 - S.lo) * 100)}% of their length`, SC.short.length, GS.short, nf, "warn") +
|
||
row(`Pings faster than light in ${S.vac ? "vacuum" : "fibre"}`, SC.rt.length, GS.rt, nAct.rt, "bad") +
|
||
row("Rail and road shorter than the straight line", SC.rr.length, GS.rr, nAct.rr, "bad") +
|
||
row("Satellite passes", SC.sat.length, GS.sat, nAct.sat, "bad") +
|
||
`<tr><th scope="row">Surveyed triangles (any plane)</th><td class="bad">${fmt(D.stats.tri.flat)}<span class="of"> / ${fmt(D.stats.tri.n)}</span></td><td class="${D.stats.tri.globe ? "bad" : "ok"}">${fmt(D.stats.tri.globe)}</td></tr>`;
|
||
const worst = [...SC.long.map(x => ["f", x[0], x[1]]), ...[...SC.rt, ...SC.rr, ...SC.sat].map(x => ["l", x[0], x[1]])].sort((p, q) => q[2] - p[2]).slice(0, 6);
|
||
$("#worst").innerHTML = worst.length ? worst.map(([t, x, r]) => `<li data-a="${x.a}" data-b="${x.b}"><b>${cityName(x.a)} – ${cityName(x.b)}</b><span style="color:${COL.bad}">${t === "f" ? "×" + r.toFixed(2) + " top speed" : "×" + r.toFixed(2) + " " + (x.kind === "rt" ? "light" : "limit")}</span></li>`).join("")
|
||
: `<li><b>Nothing faster than possible</b><span></span></li>`;
|
||
drawHist();
|
||
}
|
||
|
||
/* ---------------- 2D drawing ---------------- */
|
||
const cv = $("#map"), g = cv.getContext("2d");
|
||
const landC = document.createElement("canvas"), lg = landC.getContext("2d"), maskC = document.createElement("canvas"), mg = maskC.getContext("2d");
|
||
let placed = []; // label boxes drawn this frame
|
||
let W = 0, H = 0, DPR = 1;
|
||
function resize() {
|
||
DPR = Math.min(2, window.devicePixelRatio || 1);
|
||
const r = cv.getBoundingClientRect(); W = r.width; H = r.height; cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
|
||
const r3 = c3.getBoundingClientRect(); W3 = r3.width; H3 = r3.height; c3.width = Math.round(W3 * DPR); c3.height = Math.round(H3 * DPR);
|
||
const rh = hc.getBoundingClientRect(); hc.width = Math.round(rh.width * DPR); hc.height = Math.round(rh.height * DPR);
|
||
drawHist();
|
||
}
|
||
function fitView(X) {
|
||
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
||
for (let i = 0; i < N; i++) { const x = X[2 * i], y = X[2 * i + 1]; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
||
const pad = 0.08, s = Math.min(W / ((x1 - x0) * (1 + 2 * pad) || 1), H / ((y1 - y0) * (1 + 2 * pad) || 1));
|
||
return { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, s };
|
||
}
|
||
const P = (x, y) => [W / 2 + (x - view.cx) * view.s, H / 2 - (y - view.cy) * view.s];
|
||
function drawMap() {
|
||
if (!W) return;
|
||
const X = sv.X;
|
||
const target = fitView(X);
|
||
if (!view) view = { ...target };
|
||
else if (!userView) { const k = 0.12; view.cx += (target.cx - view.cx) * k; view.cy += (target.cy - view.cy) * k; view.s *= Math.pow(target.s / view.s, k); }
|
||
g.setTransform(DPR, 0, 0, DPR, 0, 0); g.clearRect(0, 0, W, H);
|
||
g.fillStyle = "#040a16"; g.fillRect(0, 0, W, H);
|
||
// coastlines
|
||
if (coastAt !== sv.total) { coast = warp(X, 2); coastAt = sv.total; }
|
||
g.lineJoin = "round";
|
||
const land = (c, fill) => {
|
||
for (const r of SHEET.rings) {
|
||
if (r.fill !== fill) continue;
|
||
c.beginPath();
|
||
r.idx.forEach((p, k) => { const q = P(coast[2 * p], coast[2 * p + 1]); k ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1]); });
|
||
if (fill) { c.closePath(); c.fillStyle = COL.land; c.fill(); c.strokeStyle = COL.coast; c.lineWidth = 0.8; c.stroke(); }
|
||
else { c.strokeStyle = "rgba(58,90,128,.55)"; c.lineWidth = 0.8; c.setLineDash([3, 3]); c.stroke(); c.setLineDash([]); }
|
||
}
|
||
};
|
||
// only land near a city has its shape fixed by measurements: land fades from 900 km to 1,600 km from the nearest city
|
||
if (landC.width !== cv.width || landC.height !== cv.height) { landC.width = maskC.width = cv.width; landC.height = maskC.height = cv.height; }
|
||
lg.setTransform(DPR, 0, 0, DPR, 0, 0); lg.globalCompositeOperation = "source-over"; lg.clearRect(0, 0, W, H); lg.lineJoin = "round"; land(lg, true);
|
||
mg.setTransform(DPR, 0, 0, DPR, 0, 0); mg.globalCompositeOperation = "source-over"; mg.clearRect(0, 0, W, H);
|
||
mg.fillStyle = "rgba(255,255,255,.28)"; mg.fillRect(0, 0, W, H); mg.globalCompositeOperation = "lighter";
|
||
const r0 = 900 * view.s, r1 = 1600 * view.s;
|
||
for (let i = 0; i < N; i++) {
|
||
const q = P(X[2 * i], X[2 * i + 1]); if (q[0] < -r1 || q[1] < -r1 || q[0] > W + r1 || q[1] > H + r1) continue;
|
||
const gr = mg.createRadialGradient(q[0], q[1], r0, q[0], q[1], r1); gr.addColorStop(0, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
|
||
mg.fillStyle = gr; mg.beginPath(); mg.arc(q[0], q[1], r1, 0, 2 * Math.PI); mg.fill();
|
||
}
|
||
lg.setTransform(1, 0, 0, 1, 0, 0); lg.globalCompositeOperation = "destination-in"; lg.drawImage(maskC, 0, 0); lg.globalCompositeOperation = "source-over";
|
||
g.setTransform(1, 0, 0, 1, 0, 0); g.drawImage(landC, 0, 0); g.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
land(g, false);
|
||
// each city's move from the starting map
|
||
if (ORIG) {
|
||
g.lineWidth = 0.8; g.strokeStyle = "rgba(143,167,196,.3)"; g.beginPath();
|
||
for (let i = 0; i < N; i++) { const a = P(ORIG[2 * i], ORIG[2 * i + 1]), b = P(X[2 * i], X[2 * i + 1]); if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 3) continue; g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1]); }
|
||
g.stroke();
|
||
g.fillStyle = "rgba(143,167,196,.5)"; g.beginPath();
|
||
for (let i = 0; i < N; i++) { const a = P(ORIG[2 * i], ORIG[2 * i + 1]), b = P(X[2 * i], X[2 * i + 1]); if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 3) continue; g.moveTo(a[0] + 1.4, a[1]); g.arc(a[0], a[1], 1.4, 0, 7); }
|
||
g.fill();
|
||
}
|
||
// selected city: its kept measurements
|
||
if (sel >= 0) {
|
||
g.lineWidth = 1; g.strokeStyle = "rgba(61,255,160,.55)";
|
||
for (const f of act.f) if ((f.a === sel || f.b === sel)) { const r = SC.ratio[f.i]; if (r <= 1 + S.hi && r >= 1 - S.lo) seg(X, f.a, f.b); }
|
||
}
|
||
// broken measurements; with a city selected, those not touching it stay faint
|
||
for (const mine of sel < 0 ? [true] : [false, true]) {
|
||
const on = x => (sel < 0 || x.a === sel || x.b === sel) === mine;
|
||
g.globalAlpha = mine ? 1 : 0.22;
|
||
g.lineWidth = 1.6;
|
||
g.strokeStyle = COL.warn; for (const [f] of SC.short) if (on(f)) seg(X, f.a, f.b);
|
||
g.setLineDash([5, 4]); g.strokeStyle = COL.bad;
|
||
for (const k of ["rt", "rr", "sat"]) for (const [l] of SC[k]) if (on(l)) seg(X, l.a, l.b);
|
||
g.setLineDash([]); g.lineWidth = 2; g.strokeStyle = COL.bad;
|
||
for (const [f] of SC.long) if (on(f)) seg(X, f.a, f.b);
|
||
}
|
||
g.globalAlpha = 1;
|
||
// cities
|
||
for (let i = 0; i < N; i++) {
|
||
const q = P(X[2 * i], X[2 * i + 1]);
|
||
g.beginPath(); g.arc(q[0], q[1], i === sel ? 4.5 : 2.4, 0, 7); g.fillStyle = i === sel ? COL.track : COL.city || "#fff"; g.fill();
|
||
}
|
||
placed = [];
|
||
if (sel >= 0) { const q = P(X[2 * sel], X[2 * sel + 1]); label(q, cityName(sel)); }
|
||
for (const [f] of SC.long.slice().sort((p, q) => q[1] - p[1]).slice(0, 4)) for (const i of [f.a, f.b]) if (i !== sel) label(P(X[2 * i], X[2 * i + 1]), cityName(i), true);
|
||
}
|
||
function seg(X, a, b) { const p = P(X[2 * a], X[2 * a + 1]), q = P(X[2 * b], X[2 * b + 1]); g.beginPath(); g.moveTo(p[0], p[1]); g.lineTo(q[0], q[1]); g.stroke(); }
|
||
function label(q, t, small) {
|
||
g.font = `${small ? 500 : 600} ${small ? 11 : 12.5}px Inter, system-ui, sans-serif`; g.textBaseline = "middle";
|
||
const box = [q[0] + 5, q[1] - 15, q[0] + 9 + g.measureText(t).width, q[1] + 1]; // skip a label that would overlap one already drawn
|
||
if (placed.some(b => box[0] < b[2] && b[0] < box[2] && box[1] < b[3] && b[1] < box[3])) return;
|
||
placed.push(box);
|
||
g.lineWidth = 3; g.strokeStyle = "rgba(4,10,22,.9)"; g.strokeText(t, q[0] + 7, q[1] - 7); g.fillStyle = small ? COL.dim : COL.ink; g.fillText(t, q[0] + 7, q[1] - 7);
|
||
}
|
||
|
||
/* ---------------- histogram ---------------- */
|
||
const hc = $("#hist"), hg = hc.getContext("2d");
|
||
function drawHist() {
|
||
if (!sv || !hc.width) return;
|
||
const w = hc.width / DPR, h = hc.height / DPR; hg.setTransform(DPR, 0, 0, DPR, 0, 0); hg.clearRect(0, 0, w, h);
|
||
const x0 = 0.4, x1 = 2.1, nb = 85, bw = (x1 - x0) / nb, X = v => 4 + (v - x0) / (x1 - x0) * (w - 8);
|
||
const flat = new Array(nb).fill(0), glob = new Array(nb).fill(0);
|
||
for (const f of FL) { const r = SC.ratio[f.i]; if (r === r) flat[Math.max(0, Math.min(nb - 1, Math.floor((r - x0) / bw)))]++; glob[Math.max(0, Math.min(nb - 1, Math.floor((f.g / f.L - x0) / bw)))]++; }
|
||
const top = Math.max(...flat, ...glob, 1), base = h - 16, Y = v => base - v / top * (base - 6);
|
||
hg.fillStyle = "rgba(61,255,160,.07)"; hg.fillRect(X(1 - S.lo), 0, X(1 + S.hi) - X(1 - S.lo), base);
|
||
for (let k = 0; k < nb; k++) {
|
||
const v = flat[k]; if (!v) continue; const c = x0 + (k + 0.5) * bw;
|
||
hg.fillStyle = c > 1 + S.hi ? COL.bad : c < 1 - S.lo ? COL.warn : "rgba(232,240,251,.75)";
|
||
hg.fillRect(X(x0 + k * bw) + 0.5, Y(v), Math.max(1, X(x0 + (k + 1) * bw) - X(x0 + k * bw) - 1), base - Y(v));
|
||
}
|
||
hg.beginPath(); hg.strokeStyle = COL.track; hg.lineWidth = 1.4;
|
||
for (let k = 0; k < nb; k++) { const xa = X(x0 + k * bw), xb = X(x0 + (k + 1) * bw); k ? hg.lineTo(xa, Y(glob[k])) : hg.moveTo(xa, Y(glob[k])); hg.lineTo(xb, Y(glob[k])); }
|
||
hg.stroke();
|
||
hg.fillStyle = COL.faint; hg.font = "500 10.5px JetBrains Mono, monospace"; hg.textBaseline = "alphabetic";
|
||
for (const v of [0.5, 0.75, 1, 1.25, 1.5, 2]) { hg.fillText(v === 1 ? "1" : String(v), X(v) - (v === 1 ? 3 : 9), h - 3); hg.fillRect(X(v), base, 1, 3); }
|
||
hg.font = "500 11px Inter, sans-serif"; hg.fillStyle = COL.dim; hg.fillText("plane", w - 100, 12); hg.fillStyle = COL.track; hg.fillText("globe", w - 52, 12);
|
||
}
|
||
|
||
/* ---------------- interaction ---------------- */
|
||
const tip = $("#tip");
|
||
function nearestCity(mx, my) {
|
||
let best = -1, bd = 12 * 12;
|
||
for (let i = 0; i < N; i++) { const q = P(sv.X[2 * i], sv.X[2 * i + 1]); const d = (q[0] - mx) ** 2 + (q[1] - my) ** 2; if (d < bd) { bd = d; best = i; } }
|
||
return best;
|
||
}
|
||
function nearestLine(mx, my) {
|
||
let best = null, bd = 6 * 6;
|
||
const test = (x, t, r) => {
|
||
if (sel >= 0 && x.a !== sel && x.b !== sel) return;
|
||
const p = P(sv.X[2 * x.a], sv.X[2 * x.a + 1]), q = P(sv.X[2 * x.b], sv.X[2 * x.b + 1]);
|
||
const dx = q[0] - p[0], dy = q[1] - p[1], L2 = dx * dx + dy * dy; const u = L2 ? Math.max(0, Math.min(1, ((mx - p[0]) * dx + (my - p[1]) * dy) / L2)) : 0;
|
||
const d = (p[0] + u * dx - mx) ** 2 + (p[1] + u * dy - my) ** 2; if (d < bd) { bd = d; best = [t, x, r]; }
|
||
};
|
||
for (const [f, r] of SC.long) test(f, "f", r);
|
||
for (const [f, r] of SC.short) test(f, "f", r);
|
||
for (const k of ["rt", "rr", "sat"]) for (const [l, r] of SC[k]) test(l, "l", r);
|
||
if (sel >= 0) for (const f of act.f) if (f.a === sel || f.b === sel) test(f, "f", SC.ratio[f.i]);
|
||
return best;
|
||
}
|
||
const hm = m => `${Math.floor(m / 60)}h ${String(Math.round(m % 60)).padStart(2, "0")}m`;
|
||
function flightTip(f, r, planeKm) {
|
||
const sp = (f.ab[4] + f.ba[4]) / 2;
|
||
const state = r > 1 + S.hi ? `<b style="color:${COL.bad}">needs ${fmt(sp * r)} km/h, ×${r.toFixed(2)} its cruise speed</b>` : r < 1 - S.lo ? `<b style="color:${COL.warn}">route ${Math.round((1 / r - 1) * 100)}% longer than the straight line</b>` : `<b style="color:${COL.ok}">kept</b>`;
|
||
return `<b>${cityName(f.a)} – ${cityName(f.b)}</b><span>${f.airline} ${f.fa}/${f.fb} · ${f.ab[1]}/${f.ba[1]}</span><span>In the air ${hm(f.ab[2])} / ${hm(f.ba[2])} · length ${fmt(f.L)} km</span><span>This plane ${fmt(planeKm)} km · globe ${fmt(f.g)} km</span>${state}`;
|
||
}
|
||
function limTip(l, r, planeKm) {
|
||
return `<b>${cityName(l.a)} – ${cityName(l.b)}</b><span>${KIND[l.kind]}: ${l.note}</span>${l.label ? `<span>${l.label}</span>` : ""}<span>Limit ${fmt(l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U)} km · this plane ${fmt(planeKm)} km · globe ${fmt(l.g)} km</span>${r > 1.0005 ? `<b style="color:${COL.bad}">broken ×${r.toFixed(2)}</b>` : ""}`;
|
||
}
|
||
function showTip(html, x, y) { tip.innerHTML = html; tip.hidden = false; const r = tip.getBoundingClientRect(); tip.style.left = Math.min(innerWidth - r.width - 8, x + 14) + "px"; tip.style.top = Math.min(innerHeight - r.height - 8, y + 14) + "px"; }
|
||
let drag = null;
|
||
cv.addEventListener("pointerdown", e => { drag = { x: e.clientX, y: e.clientY, cx: view.cx, cy: view.cy, moved: false }; cv.setPointerCapture(e.pointerId); });
|
||
cv.addEventListener("pointermove", e => {
|
||
const r = cv.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
|
||
if (drag) { const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (Math.abs(dx) + Math.abs(dy) > 3) { drag.moved = true; userView = true; view.cx = drag.cx - dx / view.s; view.cy = drag.cy + dy / view.s; } return; }
|
||
const c = nearestCity(mx, my);
|
||
if (c >= 0) { const nb = [...SC.long, ...SC.short].filter(([f]) => f.a === c || f.b === c).length; const nf = FL.filter(f => f.a === c || f.b === c).length;
|
||
showTip(`<b>${cityName(c)}</b><span>${D.cities[c][4]} · ${D.cities[c][3]}</span><span>${nf} routes, ${nb} broken here</span>`, e.clientX, e.clientY); return; }
|
||
const l = nearestLine(mx, my);
|
||
if (l) { const km = dist(sv.X, 2, l[1].a, l[1].b); showTip(l[0] === "f" ? flightTip(l[1], l[2], km) : limTip(l[1], l[2], km), e.clientX, e.clientY); return; }
|
||
tip.hidden = true;
|
||
});
|
||
cv.addEventListener("pointerup", e => {
|
||
const r = cv.getBoundingClientRect();
|
||
if (drag && !drag.moved) { const c = nearestCity(e.clientX - r.left, e.clientY - r.top); sel = c === sel ? -1 : c; }
|
||
drag = null;
|
||
});
|
||
cv.addEventListener("pointerleave", () => { tip.hidden = true; });
|
||
cv.addEventListener("wheel", e => { e.preventDefault(); userView = true; const r = cv.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top;
|
||
const wx = view.cx + (mx - W / 2) / view.s, wy = view.cy - (my - H / 2) / view.s; const k = Math.exp(-e.deltaY * 0.0015); view.s *= k;
|
||
view.cx = wx - (mx - W / 2) / view.s; view.cy = wy + (my - H / 2) / view.s; }, { passive: false });
|
||
cv.addEventListener("dblclick", () => { userView = false; });
|
||
$("#worst").addEventListener("click", e => { const li = e.target.closest("li[data-a]"); if (li) { sel = +li.dataset.a; } });
|
||
|
||
for (const b of document.querySelectorAll("#starts button")) b.addEventListener("click", () => {
|
||
S.start = b.dataset.start; for (const x of document.querySelectorAll("#starts button")) x.setAttribute("aria-pressed", String(x === b)); sel = -1; restart();
|
||
});
|
||
$("#run").addEventListener("click", () => {
|
||
if (sv.done) { continueSolve(); return; }
|
||
running = !running; $("#run").textContent = running ? "Pause" : "Solve";
|
||
});
|
||
$("#reset").addEventListener("click", () => restart());
|
||
for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; continueSolve(); });
|
||
$("#lo").addEventListener("input", e => { $("#lov").textContent = e.target.value + "%"; });
|
||
$("#hi").addEventListener("input", e => { $("#hiv").textContent = e.target.value + "%"; });
|
||
$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; continueSolve(); });
|
||
$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; continueSolve(); });
|
||
for (const b of document.querySelectorAll("#light button")) b.addEventListener("click", () => {
|
||
S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); continueSolve();
|
||
});
|
||
$("#dl").addEventListener("click", () => {
|
||
const blob = new Blob([JSON.stringify(D)], { type: "application/json" }); const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob); a.download = "best-flat-map.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||
});
|
||
|
||
/* ---------------- 3D ---------------- */
|
||
const c3 = $("#c3"), g3 = c3.getContext("2d");
|
||
let W3 = 0, H3 = 0, s3 = null, yaw = 0.4, pitch = -0.5, spin = true, coast3 = null, coast3At = -1, sph = null, flatWrap = null;
|
||
function sphereFit(X) { // least-squares sphere through the points; reach = angle from mean direction to farthest point
|
||
const A = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], B = [0, 0, 0, 0];
|
||
for (let i = 0; i < N; i++) { const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2]; const row = [2 * x, 2 * y, 2 * z, 1], rhs = x * x + y * y + z * z; for (let p = 0; p < 4; p++) { B[p] += row[p] * rhs; for (let q = 0; q < 4; q++) A[p][q] += row[p] * row[q]; } }
|
||
const M = A.map((r, i) => r.concat([B[i]]));
|
||
for (let i = 0; i < 4; i++) { let p = i; for (let r = i + 1; r < 4; r++) if (Math.abs(M[r][i]) > Math.abs(M[p][i])) p = r; [M[i], M[p]] = [M[p], M[i]]; if (Math.abs(M[i][i]) < 1e-12) return null;
|
||
for (let r = 0; r < 4; r++) { if (r === i) continue; const f = M[r][i] / M[i][i]; for (let c = i; c <= 4; c++) M[r][c] -= f * M[i][c]; } }
|
||
const s = M.map((r, i) => r[4] / r[i]); const c = s.slice(0, 3); const rad = Math.sqrt(s[3] + c[0] ** 2 + c[1] ** 2 + c[2] ** 2);
|
||
const off = [], u = []; let m = [0, 0, 0];
|
||
for (let i = 0; i < N; i++) { const v = [X[3 * i] - c[0], X[3 * i + 1] - c[1], X[3 * i + 2] - c[2]]; const d = Math.hypot(...v); off.push(Math.abs(d - rad) / rad); const w = v.map(t => t / d); u.push(w); m = m.map((t, k) => t + w[k]); }
|
||
const ml = Math.hypot(...m); m = m.map(t => t / ml); let wrap = 0; for (const w of u) wrap = Math.max(wrap, Math.acos(Math.max(-1, Math.min(1, w[0] * m[0] + w[1] * m[1] + w[2] * m[2]))));
|
||
off.sort((p, q) => p - q); return { rad, off: off[N >> 1], wrap: wrap / D2R, c };
|
||
}
|
||
function lift() {
|
||
const X0 = new Float64Array(N * 3); let seed = 7;
|
||
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
|
||
for (let i = 0; i < N; i++) { const u = Math.max(1e-9, rnd()), v = rnd(); X0[3 * i] = sv.X[2 * i]; X0[3 * i + 1] = sv.X[2 * i + 1]; X0[3 * i + 2] = 50 * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }
|
||
s3 = makeSolver(3, X0); s3.flatBroken = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length; coast3At = -1; $("#lift").textContent = "Release again";
|
||
}
|
||
function rot(x, y, z) { // yaw about vertical, then pitch
|
||
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
|
||
const x1 = cy * x + sy * z, z1 = -sy * x + cy * z; return [x1, cp * y - sp * z1, sp * y + cp * z1];
|
||
}
|
||
function draw3() {
|
||
if (!W3) return;
|
||
g3.setTransform(DPR, 0, 0, DPR, 0, 0); g3.clearRect(0, 0, W3, H3); g3.fillStyle = "#040a16"; g3.fillRect(0, 0, W3, H3);
|
||
const X = s3 ? s3.X : null;
|
||
if (!X) { g3.fillStyle = COL.faint; g3.font = "500 13px Inter, sans-serif"; g3.textAlign = "center"; g3.fillText("Release the flat map into 3D to start", W3 / 2, H3 / 2); g3.textAlign = "start"; return; }
|
||
let c = [0, 0, 0]; for (let i = 0; i < N; i++) for (let q = 0; q < 3; q++) c[q] += X[3 * i + q] / N;
|
||
let rmax = 1; for (let i = 0; i < N; i++) rmax = Math.max(rmax, Math.hypot(X[3 * i] - c[0], X[3 * i + 1] - c[1], X[3 * i + 2] - c[2]));
|
||
const sc = Math.min(W3, H3) * 0.44 / rmax;
|
||
const pj = (x, y, z) => { const r = rot(x - c[0], y - c[1], z - c[2]); return [W3 / 2 + r[0] * sc, H3 / 2 - r[1] * sc, r[2]]; };
|
||
if (coast3At !== s3.total) { coast3 = warp(X, 3); coast3At = s3.total; }
|
||
if (s3.done && sph) { // the sphere fitted after the solve, drawn for reference only
|
||
const q = pj(...sph.c); g3.beginPath(); g3.arc(q[0], q[1], sph.rad * sc, 0, 7); g3.fillStyle = "rgba(62,230,255,.035)"; g3.fill();
|
||
g3.setLineDash([3, 5]); g3.strokeStyle = "rgba(62,230,255,.35)"; g3.lineWidth = 1; g3.stroke(); g3.setLineDash([]);
|
||
}
|
||
g3.lineWidth = 0.9;
|
||
for (const r of SHEET.rings) { // land within reach of the cities; far coasts would only be extrapolated
|
||
if (!r.fill) continue;
|
||
let prev = null, pn = 0;
|
||
for (const p of r.idx) {
|
||
const q = pj(coast3[3 * p], coast3[3 * p + 1], coast3[3 * p + 2]);
|
||
if (prev && pn && SHEET.near[p]) { g3.strokeStyle = (q[2] + prev[2]) / 2 > 0 ? "rgba(120,170,230,.95)" : "rgba(58,90,128,.3)"; g3.beginPath(); g3.moveTo(prev[0], prev[1]); g3.lineTo(q[0], q[1]); g3.stroke(); }
|
||
prev = q; pn = SHEET.near[p];
|
||
}
|
||
}
|
||
const sc3 = score(X, 3);
|
||
g3.lineWidth = 1.6;
|
||
const L3 = (a, b, col, dash) => { const p = pj(X[3 * a], X[3 * a + 1], X[3 * a + 2]), q = pj(X[3 * b], X[3 * b + 1], X[3 * b + 2]); g3.setLineDash(dash || []); g3.strokeStyle = col; g3.beginPath(); g3.moveTo(p[0], p[1]); g3.lineTo(q[0], q[1]); g3.stroke(); };
|
||
for (const [f] of sc3.short) L3(f.a, f.b, COL.warn);
|
||
for (const k of ["rt", "rr", "sat"]) for (const [l] of sc3[k]) L3(l.a, l.b, COL.bad, [5, 4]);
|
||
for (const [f] of sc3.long) L3(f.a, f.b, COL.bad);
|
||
g3.setLineDash([]);
|
||
for (let i = 0; i < N; i++) { const q = pj(X[3 * i], X[3 * i + 1], X[3 * i + 2]); g3.beginPath(); g3.arc(q[0], q[1], 2.2, 0, 7); g3.fillStyle = q[2] > 0 ? "#fff" : "rgba(255,255,255,.35)"; g3.fill(); }
|
||
const nb = sc3.long.length + sc3.short.length + sc3.rt.length + sc3.rr.length + sc3.sat.length;
|
||
$("#r3b").textContent = fmt(nb); $("#r3s").textContent = s3.done ? "done" : STAGE(s3.stage);
|
||
$("#r3f").textContent = fmt(s3.flatBroken);
|
||
if (s3.total % 10 === 0 || s3.done) { sph = sphereFit(X); }
|
||
if (sph) { $("#r3w").textContent = Math.round(sph.wrap) + "°"; $("#r3r").textContent = fmt(sph.rad) + " km"; $("#r3o").textContent = (sph.off * 100).toFixed(1) + "%"; }
|
||
}
|
||
c3.addEventListener("pointerdown", e => { const d = { x: e.clientX, y: e.clientY, yaw, pitch }; c3.setPointerCapture(e.pointerId);
|
||
const mv = ev => { yaw = d.yaw + (ev.clientX - d.x) * 0.008; pitch = Math.max(-1.5, Math.min(1.5, d.pitch + (ev.clientY - d.y) * 0.008)); };
|
||
const up = () => { c3.removeEventListener("pointermove", mv); c3.removeEventListener("pointerup", up); }; c3.addEventListener("pointermove", mv); c3.addEventListener("pointerup", up); });
|
||
$("#lift").addEventListener("click", lift);
|
||
$("#spin").addEventListener("click", e => { spin = !spin; e.target.setAttribute("aria-pressed", String(spin)); e.target.textContent = spin ? "Turning" : "Still"; });
|
||
|
||
/* ---------------- results table ---------------- */
|
||
$("#bands tbody").innerHTML = D.bands.map(b => `<tr><th scope="row">${Math.round((1 - b.lo) * 100)}% – ${Math.round((1 + b.hi) * 100)}%</th><td>${b.flat.long}</td><td>${b.flat.short}</td><td>${b.flat.limits}</td><td>${b.flat.below75}</td><td>${b.globe.long}</td><td>${b.globe.short}</td><td>${b.globe.limits}</td></tr>`).join("");
|
||
|
||
/* ---------------- loop ---------------- */
|
||
// a solve started from a map runs slowly at first, where the cities move most, so the move can be followed
|
||
const pace = sv => sv.total < 100 ? 0.35 : sv.stage === 0 ? (sv.total < 200 ? 1 : 10) : sv.stage === 1 ? 12 : 400;
|
||
let frame = 0, capText = "";
|
||
function caption(k) {
|
||
let t;
|
||
if (!sv.done && sv.total === 0 && !running)
|
||
t = `<b>${START_LABEL[S.start]}, as drawn: ${fmt(k)} measurements broken.</b> Red: a flight longer than its aircraft can fly, or (dashed) a ping faster than light. Amber: a flight far shorter than it takes to fly. Press <b>Solve</b> to let the cities move.`;
|
||
else if (!sv.done)
|
||
t = `${running ? "Moving" : "Paused moving"} the cities to fit the measurements. Grey lines: each city's move from the starting map.`;
|
||
else if (sel >= 0) {
|
||
let n = 0, kept = 0, long = 0, short = 0;
|
||
for (const f of act.f) if (f.a === sel || f.b === sel) { n++; const r = SC.ratio[f.i]; if (r > 1 + S.hi) long++; else if (r < 1 - S.lo) short++; else kept++; }
|
||
t = `<b>${cityName(sel)}</b>: ${kept} of ${n} flights kept (<span class="k-ok">green</span>)` + (long ? `, ${long} longer than the aircraft can fly (<span class="k-bad">red</span>)` : "") + (short ? `, ${short} squeezed short (<span class="k-warn">amber</span>)` : "") + ". Click any city to see its flights.";
|
||
} else t = `${sv.isBest ? "Best of " + D.stats.n_starts : "Done"}: ${fmt(k)} measurements broken. Click any city to see its flights.`;
|
||
if (t !== capText) { $("#mapcap").innerHTML = t; capText = t; }
|
||
}
|
||
function loop() {
|
||
frame++;
|
||
if (sv && running && !sv.done) {
|
||
if (sv.watch) { sv.acc += pace(sv); const n = Math.floor(sv.acc); sv.acc -= n; if (n) work(sv, 9, n); } else work(sv, 9, 400);
|
||
if (sv.done) { running = false; $("#run").textContent = "Solve"; if (sv.watch && sel < 0) sel = SANTIAGO; }
|
||
}
|
||
if (sv && (running || frame % 6 === 0)) update(frame % 30 === 0);
|
||
if (sv) {
|
||
const k = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length;
|
||
const head = sv.done ? (sv.isBest ? `Best of ${D.stats.n_starts}` : "Done") : sv.total === 0 && !running ? `${START_LABEL[S.start]}, as drawn` : (running ? "Solving, " : "Paused, ") + STAGE(sv.stage);
|
||
$("#status").innerHTML = `${head} · broken <b>${fmt(k)}</b>`;
|
||
caption(k);
|
||
}
|
||
drawMap();
|
||
if (s3 && !s3.done) work(s3, 7, 300);
|
||
if (spin && s3) yaw += 0.003;
|
||
draw3();
|
||
requestAnimationFrame(loop);
|
||
}
|
||
new ResizeObserver(resize).observe(document.body);
|
||
resize(); restart(); requestAnimationFrame(loop);
|
||
</script>
|
||
</body>
|
||
</html>
|