01ffa67de6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
986 lines
75 KiB
HTML
986 lines
75 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}
|
||
#arc[aria-pressed="true"]{border-color:var(--warn);color:var(--warn);background:rgba(255,201,74,.08)}
|
||
.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="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, without the plane.</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><button type="button" class="btn" id="arc" aria-pressed="false">Measure along a sphere</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 id="r3rL">Best-fit sphere radius</dt><dd id="r3r">–</dd></div>
|
||
<div><dt id="r3oL">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" id="note3"></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. Measured along a sphere instead, with the shape conceded and its size left free (<i>Measure along a sphere</i>, above), the flights fit a sphere of radius {{ARC_R}} km with {{ARC_BROKEN}} measurements broken, and the cities fall a median {{ARC_OFF}} km from their true places. Earth's mean radius is 6,371 km; routes flown run slightly longer than the shortest path.</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>Along a sphere</dt><dd>Optional, and it assumes the shape. The flat layout is laid on a spherical cap whose radius is a third of the layout's width; the cities are then held on a sphere and each distance is measured along its surface (radius × central angle, the haversine distance). The first stage fits the radius with the cities; the later stages hold it. Land is placed by the rotation or reflection that best lines the cities' true positions up with the solved ones (orthogonal Procrustes); the distance of each city from its true place is measured after it.</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; sv.dist = (X, a, b) => dist(X, dim, a, b);
|
||
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 = sv.dist(X, 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, sv.dist(X, 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.arc && sv.stage === 0) sv.Rfit = sv.X[NR];
|
||
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}`;
|
||
|
||
/* ---------------- measured along a sphere (optional; assumes the shape, leaves its size free) ---------------- */
|
||
const NR = 3 * N; // index of the radius among an along-the-sphere solver's variables
|
||
let aU0 = 0, aU1 = 0, aU2 = 0, aV0 = 0, aV1 = 0, aV2 = 0, aC = 0, aS = 1, aNa = 1, aNb = 1;
|
||
function arcGeom(X, a, b) { // central angle between cities a and b; keeps what its gradient needs
|
||
const ax = X[3 * a], ay = X[3 * a + 1], az = X[3 * a + 2], bx = X[3 * b], by = X[3 * b + 1], bz = X[3 * b + 2];
|
||
aNa = Math.sqrt(ax * ax + ay * ay + az * az); aNb = Math.sqrt(bx * bx + by * by + bz * bz);
|
||
aU0 = ax / aNa; aU1 = ay / aNa; aU2 = az / aNa; aV0 = bx / aNb; aV1 = by / aNb; aV2 = bz / aNb;
|
||
aC = aU0 * aV0 + aU1 * aV1 + aU2 * aV2;
|
||
const x = aU1 * aV2 - aU2 * aV1, y = aU2 * aV0 - aU0 * aV2, z = aU0 * aV1 - aU1 * aV0, s = Math.sqrt(x * x + y * y + z * z);
|
||
aS = Math.max(s, 1e-9); return Math.atan2(s, aC);
|
||
}
|
||
function arcGrad(G, a, b, g) { // adds g x d(angle)/d(position) for the pair last passed to arcGeom
|
||
const ka = -g / (aS * aNa), kb = -g / (aS * aNb);
|
||
G[3 * a] += ka * (aV0 - aC * aU0); G[3 * a + 1] += ka * (aV1 - aC * aU1); G[3 * a + 2] += ka * (aV2 - aC * aU2);
|
||
G[3 * b] += kb * (aU0 - aC * aV0); G[3 * b + 1] += kb * (aU1 - aC * aV1); G[3 * b + 2] += kb * (aU2 - aC * aV2);
|
||
}
|
||
const arcDist = (X, a, b) => X[NR] * arcGeom(X, a, b);
|
||
function makeArcSolver(X2) { // the flat layout laid on a spherical cap whose radius is a third of the layout's width
|
||
let mx = 0, my = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
||
for (let i = 0; i < N; i++) { const x = X2[2 * i], y = X2[2 * i + 1]; mx += x / N; my += y / N; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
||
const R0 = Math.max(x1 - x0, y1 - y0) / 3, n = NR + 1, X0 = new Float64Array(n);
|
||
for (let i = 0; i < N; i++) { X0[3 * i] = X2[2 * i] - mx; X0[3 * i + 1] = X2[2 * i + 1] - my; X0[3 * i + 2] = R0; }
|
||
X0[NR] = R0;
|
||
const sv = { dim: 3, arc: true, X: 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: [], done: false, best: null, bestK: Infinity, acc: 0, Rfit: null, stall: 0 };
|
||
sv.evalF = (X, G) => {
|
||
G.fill(0); let F = 0, gR = 0; const R = X[NR]; const { fa, fb, fL, la, lb, lU } = act; const lo = S.lo, hi = S.hi;
|
||
for (let k = 0; k < fa.length; k++) {
|
||
const th = arcGeom(X, fa[k], fb[k]), dd = R * th; let ga;
|
||
if (sv.stage === 0) { const e = dd / fL[k] - 1; F += e * e; ga = 2 * e / fL[k]; }
|
||
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);
|
||
}
|
||
arcGrad(G, fa[k], fb[k], ga * R); gR += ga * th;
|
||
}
|
||
for (let k = 0; k < la.length; k++) {
|
||
const th = arcGeom(X, la[k], lb[k]), dd = R * th; 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]; }
|
||
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;
|
||
}
|
||
arcGrad(G, la[k], lb[k], gl * R); gR += gl * th;
|
||
}
|
||
G[NR] = sv.stage === 0 ? gR : 0; // the radius is fitted in the first stage and held after it
|
||
return F;
|
||
};
|
||
sv.dist = arcDist;
|
||
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);
|
||
return sv;
|
||
}
|
||
// land and true city positions as unit vectors; Q turns true directions onto solved ones
|
||
const unitLL = (lat, lon) => { const p = lat * D2R, l = lon * D2R; return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)]; };
|
||
const TRUE_U = D.cities.map(c => unitLL(c[1], c[2])), DOT_U = D.dots.map(d => unitLL(d[0], d[1]));
|
||
function invT(M) { // inverse transpose of a 3 x 3 (row-major): cofactors over the determinant
|
||
const [a, b, c, d, e, f, g, h, i] = M;
|
||
const C = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
|
||
const det = a * C[0] + b * C[1] + c * C[2]; return Math.abs(det) < 1e-12 ? null : C.map(v => v / det);
|
||
}
|
||
function bestTurn(X) { // rotation or reflection Q minimising the distance from Q·true to solved (orthogonal Procrustes, polar factor)
|
||
let H = [0, 0, 0, 0, 0, 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], n = Math.hypot(x, y, z), u = [x / n, y / n, z / n], t = TRUE_U[i];
|
||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) H[3 * r + c] += u[r] * t[c] / N;
|
||
}
|
||
for (let k = 0; k < 40; k++) { const T = invT(H); if (!T) break; H = H.map((v, j) => (v + T[j]) / 2); }
|
||
return H;
|
||
}
|
||
const mul = (Q, v) => [Q[0] * v[0] + Q[1] * v[1] + Q[2] * v[2], Q[3] * v[0] + Q[4] * v[1] + Q[5] * v[2], Q[6] * v[0] + Q[7] * v[1] + Q[8] * v[2]];
|
||
const mulT = (Q, v) => [Q[0] * v[0] + Q[3] * v[1] + Q[6] * v[2], Q[1] * v[0] + Q[4] * v[1] + Q[7] * v[2], Q[2] * v[0] + Q[5] * v[1] + Q[8] * v[2]];
|
||
|
||
/* ---------------- 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;
|
||
}
|
||
// land dots for the 3D view, within 1,600 km of a city on the starting map
|
||
const di = [], dk = [];
|
||
D.dots.forEach(([lat, lon], j) => {
|
||
const p = aeq(lat, lon, centre[0], centre[1]); if (p[2] > 168 * D2R) return;
|
||
const x = p[0] / U, y = p[1] / U; let dmin = Infinity; for (let i = 0; i < n; i++) dmin = Math.min(dmin, (x - sx[i]) ** 2 + (y - sy[i]) ** 2);
|
||
if (dmin < 1.6 * 1.6) { di.push(j); dk.push(x, y); }
|
||
});
|
||
const MD = di.length, KD = new Float32Array(MD * m);
|
||
for (let p = 0; p < MD; p++) {
|
||
const x = dk[2 * p], y = dk[2 * p + 1];
|
||
for (let j = 0; j < n; j++) { const dx = x - sx[j], dy = y - sy[j]; KD[p * m + j] = tpsU(dx * dx + dy * dy); }
|
||
KD[p * m + n] = 1; KD[p * m + n + 1] = x; KD[p * m + n + 2] = y;
|
||
}
|
||
SHEET = { Ainv, K, M, m, rings, near, out2: new Float32Array(M * 2), out3: new Float32Array(M * 3), KD, MD, outD: new Float32Array(MD * 3) };
|
||
}
|
||
function warpDots(X) { // the same spline, at the land dots, in 3D
|
||
const { Ainv, KD, MD, m } = SHEET; const W = new Float64Array(m * 3);
|
||
for (let r = 0; r < m; r++) for (let q = 0; q < 3; q++) { let s = 0; for (let j = 0; j < N; j++) s += Ainv[r * m + j] * X[j * 3 + q]; W[r * 3 + q] = s; }
|
||
for (let p = 0; p < MD; p++) for (let q = 0; q < 3; q++) { let s = 0; const o = p * m; for (let r = 0; r < m; r++) s += KD[o + r] * W[r * 3 + q]; SHEET.outD[p * 3 + q] = s; }
|
||
return SHEET.outD;
|
||
}
|
||
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 dataChanged() { // data or tolerance changed: re-score the current layout; only Solve moves the cities
|
||
if (!sv) return restart();
|
||
buildActive();
|
||
const asDrawn = sv.total === 0 && !sv.done && !sv.changed, watch = sv.watch, X = Float64Array.from(sv.X);
|
||
sv = makeSolver(2, X);
|
||
if (running) sv.watch = watch; // mid-solve: carry on with the new set
|
||
else { sv.watch = asDrawn; sv.changed = !asDrawn; $("#run").textContent = "Solve"; }
|
||
update(true);
|
||
}
|
||
function continueSolve() { // Solve pressed on a finished layout: solve again from it
|
||
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, d = (a, b) => dist(X, dim, a, b)) {
|
||
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 = d(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 = d(l.a, l.b) / U;
|
||
if (r > 1.0005) { const k = l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"; out[k].push([l, r]); out.lim.set(l.i, r); }
|
||
}
|
||
return out;
|
||
}
|
||
function globeScore() {
|
||
const o = { long: 0, short: 0, rt: 0, rr: 0, sat: 0 };
|
||
if (S.fl) for (const f of FL) { const r = f.g / f.L; if (r > 1 + S.hi) o.long++; else if (r < 1 - S.lo) o.short++; }
|
||
for (const l of act.l) { const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; if (l.g > U * 1.0005) o[l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"]++; }
|
||
return o;
|
||
}
|
||
let SC = null, GS = null;
|
||
function update(full) {
|
||
SC = score(sv.X, 2); if (full) GS = globeScore();
|
||
const nAct = { rt: act.l.filter(l => l.kind === "rt").length, rr: act.l.filter(l => l.kind === "rail" || l.kind === "road").length, sat: act.l.filter(l => l.kind === "sat").length };
|
||
const row = (label, a, b, n, cls) => `<tr><th scope="row">${label}</th><td class="${a ? cls : "ok"}">${fmt(a)}<span class="of"> / ${fmt(n)}</span></td><td class="${b ? cls : "ok"}">${fmt(b)}</td></tr>`;
|
||
const nf = S.fl ? FL.length : 0;
|
||
$("#score").innerHTML =
|
||
row("Flights longer than the aircraft can fly", SC.long.length, GS.long, nf, "bad") +
|
||
row(`Flights below ${Math.round((1 - S.lo) * 100)}% of their length`, SC.short.length, GS.short, nf, "warn") +
|
||
row(`Pings faster than light in ${S.vac ? "vacuum" : "fibre"}`, SC.rt.length, GS.rt, nAct.rt, "bad") +
|
||
row("Rail and road shorter than the straight line", SC.rr.length, GS.rr, nAct.rr, "bad") +
|
||
row("Satellite passes", SC.sat.length, GS.sat, nAct.sat, "bad") +
|
||
`<tr><th scope="row">Surveyed triangles (any plane)</th><td class="bad">${fmt(D.stats.tri.flat)}<span class="of"> / ${fmt(D.stats.tri.n)}</span></td><td class="${D.stats.tri.globe ? "bad" : "ok"}">${fmt(D.stats.tri.globe)}</td></tr>`;
|
||
const worst = [...SC.long.map(x => ["f", x[0], x[1]]), ...[...SC.rt, ...SC.rr, ...SC.sat].map(x => ["l", x[0], x[1]])].sort((p, q) => q[2] - p[2]).slice(0, 6);
|
||
$("#worst").innerHTML = worst.length ? worst.map(([t, x, r]) => `<li data-a="${x.a}" data-b="${x.b}"><b>${cityName(x.a)} – ${cityName(x.b)}</b><span style="color:${COL.bad}">${t === "f" ? "×" + r.toFixed(2) + " top speed" : "×" + r.toFixed(2) + " " + (x.kind === "rt" ? "light" : "limit")}</span></li>`).join("")
|
||
: `<li><b>Nothing faster than possible</b><span></span></li>`;
|
||
drawHist();
|
||
}
|
||
|
||
/* ---------------- 2D drawing ---------------- */
|
||
const cv = $("#map"), g = cv.getContext("2d");
|
||
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; dataChanged(); });
|
||
$("#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; dataChanged(); });
|
||
$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; dataChanged(); });
|
||
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)); dataChanged();
|
||
});
|
||
$("#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, dots3 = null, dots3At = -1, sph = null, ARC = false, Q3 = null, Q3At = -1, off3 = 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() {
|
||
if (ARC) { s3 = makeArcSolver(sv.X); yaw = -1.92; pitch = 0.35; } // facing Europe and Africa, north tilted towards the viewer
|
||
else {
|
||
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;
|
||
dots3At = -1; Q3At = -1; off3 = null; sph = null; $("#lift").textContent = "Release again";
|
||
}
|
||
const NOTE3 = {
|
||
free: "No shape is assumed. The cities start from the flat map above, each nudged a few kilometres up or down, and each distance is a straight line through space. The dashed sphere is fitted afterwards to describe the result and plays no part in it. A long flight's straight line passes beneath the surface it follows, so the shape comes out larger and more open than the surface the flights describe.",
|
||
arc: "Assumes a sphere. The flat map above is laid on a spherical cap (radius a third of the map's width), the cities are held on a sphere of free radius, and each distance is measured along its surface with the haversine formula. It shows what size of sphere the flights describe and how well they fit it, not that the surface is a sphere. Land is placed by the rotation that best lines the cities' true positions up with the solved ones."
|
||
};
|
||
function mode3() {
|
||
$("#note3").textContent = NOTE3[ARC ? "arc" : "free"];
|
||
$("#r3rL").textContent = ARC ? "Radius that fits the flights best" : "Best-fit sphere radius";
|
||
$("#r3oL").textContent = ARC ? "Cities from their true place (median)" : "Median distance off it";
|
||
for (const id of ["#r3w", "#r3r", "#r3o"]) $(id).textContent = "–";
|
||
}
|
||
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 (s3.arc) return drawArc(X);
|
||
if (dots3At !== s3.total) { dots3 = warpDots(X); dots3At = 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([]);
|
||
}
|
||
const fr = new Path2D(), bk = new Path2D(); // land within reach of the cities; farther land would only be extrapolated
|
||
for (let p = 0; p < SHEET.MD; p++) { const q = pj(dots3[3 * p], dots3[3 * p + 1], dots3[3 * p + 2]); (q[2] > 0 ? fr : bk).rect(q[0] - 0.9, q[1] - 0.9, 1.8, 1.8); }
|
||
g3.fillStyle = "rgba(58,90,128,.35)"; g3.fill(bk); g3.fillStyle = "rgba(120,170,230,.7)"; g3.fill(fr);
|
||
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);
|
||
$("#arc").addEventListener("click", e => { ARC = !ARC; e.currentTarget.setAttribute("aria-pressed", String(ARC)); e.currentTarget.textContent = ARC ? "Measuring along a sphere" : "Measure along a sphere"; mode3(); if (s3) lift(); });
|
||
mode3();
|
||
function drawArc(X) { // the cities on their sphere, turned so that their true positions line up (north up)
|
||
const R = X[NR], rs = Math.min(W3, H3) * 0.42, cx = W3 / 2, cy = H3 / 2;
|
||
if (Q3At !== s3.total) { Q3 = bestTurn(X); Q3At = s3.total; }
|
||
const view = v => { const r = rot(v[0], v[2], -v[1]); return [cx + r[0] * rs, cy - r[1] * rs, r[2]]; }; // true frame, z up
|
||
const city = i => { const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2], n = Math.hypot(x, y, z); return mulT(Q3, [x / n, y / n, z / n]); };
|
||
const gr = g3.createRadialGradient(cx - rs * 0.35, cy - rs * 0.35, rs * 0.1, cx, cy, rs);
|
||
gr.addColorStop(0, "#10264a"); gr.addColorStop(1, "#060f20");
|
||
g3.beginPath(); g3.arc(cx, cy, rs, 0, 2 * Math.PI); g3.fillStyle = gr; g3.fill(); g3.strokeStyle = "rgba(62,230,255,.35)"; g3.lineWidth = 1; g3.stroke();
|
||
const land = new Path2D();
|
||
for (const t of DOT_U) { const q = view(t); if (q[2] > 0) land.rect(q[0] - 1, q[1] - 1, 2, 2); }
|
||
g3.fillStyle = "rgba(120,170,230,.55)"; g3.fill(land);
|
||
const sc3 = score(X, 3, (a, b) => arcDist(X, a, b));
|
||
const arcLine = (a, b, col, dash) => { // along the surface, front half solid, back half faint
|
||
const u = city(a), v = city(b), c = Math.max(-1, Math.min(1, u[0] * v[0] + u[1] * v[1] + u[2] * v[2])), th = Math.acos(c), sn = Math.sin(th) || 1;
|
||
g3.setLineDash(dash || []); g3.strokeStyle = col; let prev = null;
|
||
for (let k = 0; k <= 32; k++) {
|
||
const t = k / 32, wa = Math.sin((1 - t) * th) / sn, wb = Math.sin(t * th) / sn, q = view([wa * u[0] + wb * v[0], wa * u[1] + wb * v[1], wa * u[2] + wb * v[2]]);
|
||
if (prev) { g3.globalAlpha = q[2] > 0 && prev[2] > 0 ? 1 : 0.2; g3.beginPath(); g3.moveTo(prev[0], prev[1]); g3.lineTo(q[0], q[1]); g3.stroke(); }
|
||
prev = q;
|
||
}
|
||
g3.globalAlpha = 1; g3.setLineDash([]);
|
||
};
|
||
g3.lineWidth = 1.6;
|
||
for (const [f] of sc3.short) arcLine(f.a, f.b, COL.warn);
|
||
for (const k of ["rt", "rr", "sat"]) for (const [l] of sc3[k]) arcLine(l.a, l.b, COL.bad, [5, 4]);
|
||
for (const [f] of sc3.long) arcLine(f.a, f.b, COL.bad);
|
||
for (let i = 0; i < N; i++) { const q = view(city(i)); g3.beginPath(); g3.arc(q[0], q[1], 2.2, 0, 7); g3.fillStyle = q[2] > 0 ? "#fff" : "rgba(255,255,255,.18)"; 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);
|
||
$("#r3r").textContent = fmt(s3.Rfit || R) + " km";
|
||
if (s3.total % 10 === 0 || s3.done) { const P = new Float64Array(NR); for (let i = 0; i < N; i++) { const u = city(i); for (let q = 0; q < 3; q++) P[3 * i + q] = u[q] * R; } sph = sphereFit(P); }
|
||
if (sph) $("#r3w").textContent = Math.round(sph.wrap) + "°";
|
||
if (s3.done && !off3) { // each city's distance from its true place, after the turn
|
||
const o = []; for (let i = 0; i < N; i++) { const u = city(i), t = TRUE_U[i]; o.push(R * Math.acos(Math.max(-1, Math.min(1, u[0] * t[0] + u[1] * t[1] + u[2] * t[2])))); }
|
||
o.sort((p, q) => p - q); off3 = o[N >> 1];
|
||
}
|
||
$("#r3o").textContent = off3 ? fmt(off3) + " km" : "–";
|
||
}
|
||
$("#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.changed && sv.total === 0 && !running)
|
||
t = `<b>Measurements changed: ${fmt(k)} broken on this layout.</b> It was solved for the previous set. Press <b>Solve</b> to fit it to these.`;
|
||
else 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 (!S.fl)
|
||
t = `<b>Done: ${fmt(k)} measurements broken.</b> Without flight times, every measurement left is a maximum distance (no farther apart than this), and pulling cities closer always meets a maximum. These measurements can show that a map as drawn is wrong, but no flat map can fail them.`;
|
||
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 && S.fl) 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 ? (sv.changed ? "Measurements changed" : `${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>
|