Files
cable-latency-survey/satellites/template.html
T

640 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Sync Test</title>
<meta name="description" content="The ISS at its published-orbit position on the globe, North-pole AE, dual-pole AE and Mercator maps, with a spoofed radio signal moving at the measured speed of {{V_SIG}} km/s.">
<meta property="og:type" content="website">
<meta property="og:title" content="The Sync Test">
<meta property="og:description" content="ISS position and a spoofed radio signal on four maps. On the globe they stay level; one orbit after the signal is lined up, it is {{AE_LAG1}} behind on the North-pole AE map.">
<meta property="og:url" content="https://iss.aethercosmology.com/">
<meta property="og:image" content="https://iss.aethercosmology.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23081120' stroke='%233ee6ff' stroke-width='2'/%3E%3Ccircle cx='25' cy='9' r='3.5' fill='%23fff'/%3E%3Ccircle cx='22' cy='25' r='3.5' fill='none' stroke='%23ff4fd8' stroke-width='2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
/* Layout: one row of four synchronised map panels on wide screens (2 / 1 columns below), readouts under each map; text in a ~70ch column above and below. */
:root{
--bg:#02050c; --panel:#07101e; --panel2:#0b182b; --line:rgba(120,180,255,.14); --line2:rgba(120,180,255,.28);
--ink:#e8f0fb; --dim:#9badc6; --faint:#65789a;
--station:#ffffff; --signal:#ff4fd8; --track:#3ee6ff; --ok:#3dffa0; --warn:#ffc94a; --bad:#ff4d5e;
--disp:"Space Grotesk","Inter",system-ui,sans-serif;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
color-scheme:dark;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);-webkit-font-smoothing:antialiased;
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:20;background:var(--bg)}
a{color:var(--track);text-decoration-color:rgba(62,230,255,.35);text-underline-offset:2px}
a:hover{text-decoration-color:var(--track)}
:focus-visible{outline:2px solid var(--track);outline-offset:2px}
.wrap{max-width:1720px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.top{padding-block:28px 6px}
h1{font:700 clamp(28px,3.2vw,40px)/1.1 var(--disp);margin:0;letter-spacing:-.01em;text-wrap:balance}
.sub{margin:6px 0 0;color:var(--dim);font-size:15px}
.intro{display:grid;gap:10px 40px;padding-block:14px 18px}
@media (min-width:1100px){.intro{grid-template-columns:repeat(2,minmax(0,64ch))}}
.intro p{margin:0;max-width:74ch;color:var(--ink)}
.intro b{font-family:var(--disp);font-weight:600;color:var(--dim);letter-spacing:.01em}
.k-st{color:var(--station);font-weight:600}.k-sig{color:var(--signal);font-weight:600}.k-bad{color:var(--bad);font-weight:600}
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:rgba(2,5,12,.9);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-block:1px solid var(--line)}
.barin{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding-block:9px}
.sigrow{padding-block:0 9px;gap:8px 22px}
.rowlab{font:500 10.5px var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
@media (max-width:620px){.bar{position:static}}
button{font:inherit;color:inherit}
#play{width:38px;height:34px;border-radius:8px;border:1px solid var(--line2);background:var(--panel2);cursor:pointer;display:grid;place-items:center}
#play svg{width:14px;height:14px;fill:var(--ink)}
.rates{display:flex;border:1px solid var(--line2);border-radius:8px;overflow:hidden}
.rates button{border:0;background:transparent;padding:6px 10px;font:500 12.5px var(--mono);color:var(--dim);cursor:pointer}
.rates button+button{border-left:1px solid var(--line)}
.rates button[aria-pressed="true"]{background:var(--panel2);color:var(--ink)}
#time{flex:1 1 220px;min-width:140px;accent-color:var(--track)}
.sync{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;font-size:12.5px;color:var(--dim)}
.sync button{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink)}
#sync{border-color:rgba(255,79,216,.6)}
#syncinfo{font-variant-numeric:tabular-nums}
.corr{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;font-size:12.5px;color:var(--dim)}
#corr{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--dim)}
#corr[aria-pressed="true"]{color:var(--ink);border-color:rgba(255,79,216,.6)}
#latwrap{display:flex;align-items:center;gap:6px}
#latwrap[hidden]{display:none}
#lat{width:110px;accent-color:var(--signal)}
#latv{font:500 12.5px var(--mono);min-width:3.6em;color:var(--ink)}
#sync:disabled{opacity:.45;cursor:default}
.clock{display:flex;gap:12px;font:500 13px var(--mono);color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
.clock b{color:var(--ink);font-weight:500}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;padding-block:12px 0;margin-block:0;list-style:none;color:var(--dim);font-size:13px}
.legend li{display:flex;align-items:center;gap:7px}
.legend svg{width:22px;height:14px;flex:none}
.grid{display:grid;gap:12px;grid-template-columns:repeat(4,minmax(0,1fr));padding-block:12px 8px}
@media (max-width:1199px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:620px){.grid{grid-template-columns:minmax(0,1fr)}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 10px 8px;display:flex;flex-direction:column;gap:8px;min-width:0}
.ph{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:0}
.ph h2{font:600 16px/1.2 var(--disp);margin:0}
.ph p{margin:0;color:var(--faint);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.map{position:relative;aspect-ratio:1;width:100%;max-width:100%}
.map canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:pan-y}
.ro{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px;margin:0}
.ro div{min-width:0}
.ro .w{grid-column:1/-1}
.ro dt{font:500 10.5px/1.3 var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.ro dd{margin:1px 0 0;font:500 13px/1.35 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pill{display:inline-block;margin-left:6px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;border:1px solid currentColor}
.pill.ok{color:var(--ok)}.pill.warn{color:var(--warn)}.pill.bad{color:var(--bad)}
.sp{display:flex;flex-direction:column;gap:2px}
.sp span{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.sp canvas{width:100%;height:54px;display:block;cursor:pointer}
.after{padding-block:22px 10px;display:grid;gap:10px}
.after h2{font:600 19px/1.25 var(--disp);margin:14px 0 2px}
.after h3{font:600 15.5px/1.3 var(--disp);margin:6px 0 0;color:var(--ink)}
.after p b{color:var(--ink);font-weight:600}
.after p{margin:0;max-width:74ch;color:var(--dim)}
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px;max-width:980px}
table{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
th,td{padding:8px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
tr:last-child td,tr:last-child th{border-bottom:0}
thead th{font:600 12.5px var(--disp);color:var(--dim);background:var(--panel)}
tbody th{text-align:left;font-weight:500;color:var(--dim)}
td{font-family:var(--mono);font-variant-numeric:tabular-nums}
.method{display:grid;grid-template-columns:max-content minmax(0,74ch);gap:8px 20px;margin:0;color:var(--dim)}
.method dt{font:600 13.5px var(--disp);color:var(--ink);padding-top:1px}
.method dd{margin:0}
@media (max-width:620px){.method{grid-template-columns:minmax(0,1fr);gap:2px}.method dd{margin-bottom:10px}}
#dl{justify-self:start;border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:7px 14px;cursor:pointer;font-size:13.5px}
footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
#tip{position:fixed;z-index:30;pointer-events:none;background:rgba(6,12,24,.96);border:1px solid var(--line2);border-radius:8px;padding:6px 9px;font-size:12.5px;line-height:1.4;max-width:240px}
#tip b{font-weight:600}
#tip span{display:block;color:var(--dim)}
</style>
</head>
<body>
<header class="wrap top">
<h1>The Sync Test</h1>
<p class="sub">ISS position and a spoofed radio signal on four maps</p>
</header>
<section class="wrap intro">
<p><b>Hypothesis.</b> The radio signals attributed to the International Space Station come from ground transmitters along its track, imitating a source that moves at the measured speed, {{V_SIG}} km/s.</p>
<p><b>Test.</b> The <span class="k-st">station</span> is placed where the published orbit puts it, which is where it is seen to pass at the predicted times (<a href="https://spotthestation.nasa.gov/">Spot the Station</a>); its speed across each map follows from that map's distances. The <span class="k-sig">spoofed signal</span> is the source those transmitters imitate, moving along the same track at {{V_SIG}} km/s, lined up with the station at each northbound equator crossing, or at any moment with <i>Line up signal now</i>. A city turns <span class="k-bad">red</span> when the station is above its horizon and the signal is not, or the reverse.</p>
</section>
<div class="bar">
<div class="wrap barin">
<button id="play" type="button" aria-label="Pause"><svg viewBox="0 0 14 14" aria-hidden="true"><path id="playicon" d="M2 1h3.5v12H2zM8.5 1H12v12H8.5z"/></svg></button>
<div class="rates" role="group" aria-label="Playback speed">
<button type="button" data-rate="1" aria-pressed="false">×1</button>
<button type="button" data-rate="10" aria-pressed="false">×10</button>
<button type="button" data-rate="60" aria-pressed="true">×60</button>
<button type="button" data-rate="300" aria-pressed="false">×300</button>
<button type="button" data-rate="1000" aria-pressed="false">×1000</button>
</div>
<input id="time" type="range" min="0" max="1" step="10" value="0" aria-label="Time">
<div class="clock"><b id="clock">—</b><span id="orbit">—</span></div>
</div>
<div class="wrap barin sigrow">
<span class="rowlab">Signal</span>
<div class="sync">
<button id="sync" type="button">Line up signal now</button>
<button id="syncauto" type="button" hidden>Each orbit</button>
<span id="syncinfo">Lined up: each northbound equator crossing</span>
</div>
<div class="corr">
<button id="corr" type="button" aria-pressed="false">Real-time correction</button>
<label id="latwrap" hidden>Latency <input id="lat" type="range" min="0" max="9" step="1" value="4" aria-label="Correction latency"> <span id="latv">10 s</span></label>
</div>
</div>
</div>
<ul class="wrap legend">
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="4" fill="#fff"/></svg>Station, published-orbit position</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="5" fill="none" stroke="#ff4fd8" stroke-width="2"/></svg>Spoofed signal at the measured speed</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><path d="M1 7h20" stroke="#ff4fd8" stroke-opacity=".6" stroke-width="3"/></svg>Gap along the track</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="6" fill="none" stroke="#fff" stroke-opacity=".6"/></svg>Above the horizon</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#3dffa0"/></svg>City: station and signal in view</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#ff4d5e"/></svg>City: one without the other</li>
</ul>
<main class="wrap grid">
<article class="panel" data-key="globe">
<header class="ph"><h2>Globe</h2><p>Sphere, radius 6,371 km</p></header>
<div class="map"><canvas aria-label="Globe: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="ae">
<header class="ph"><h2>North-pole AE</h2><p>Centred on the North Pole</p></header>
<div class="map"><canvas aria-label="North-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="dualpole">
<header class="ph"><h2>Dual-pole AE</h2><p>Centred on 0°N 0°E</p></header>
<div class="map"><canvas aria-label="Dual-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="mercator">
<header class="ph"><h2>Mercator</h2><p>True on the equator, edges joined</p></header>
<div class="map"><canvas aria-label="Mercator map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
</main>
<section class="wrap after">
<h2>Result</h2>
<p>On the globe the signal stays level with the station by construction: both come from the published orbit (numerically within {{GLOBE_DRIFT}} km over {{N_ORBITS}} orbits). On each flat map the station's track per orbit is longer than the {{SIG_TRACK}} km the signal covers in the same time: {{AE_TRACK}} km on the North-pole map, {{ME_TRACK}} km on Mercator and {{DP_TRACK}} km on the dual-pole map. One orbit after it is lined up, at whatever moment, the signal is {{AE_LAG1}} behind on the North-pole map, {{ME_LAG1}} on Mercator and {{DP_LAG1}} on the dual-pole map. Restarted level every minute instead, it is still up to {{AE_LAG60}} adrift on the North-pole map. The recordings show no such drift: in {{SN_N}} SatNOGS recordings of three passes on 3 October 2026, the S-curves are centred within {{SN_RMS}} s (rms per pass) of the closest approaches the published orbit predicts.</p>
<h3>Other ways to send the signal</h3>
<p><b>Corrected in real time</b> (<i>Real-time correction</i>, above): transmitters that track the station and steer the signal to where it was a latency earlier keep it that latency behind. To stay with the station the signal must then cross the map at the map speed, up to {{AE_XSIG}} times the {{V_SIG}} km/s its Doppler shift shows on the North-pole map. The recordings allow neither: their S-curves are centred within {{SN_RMS}} s of the predicted closest approaches, and their shifts give {{REC_V_SOUTH}} km/s over South America and Australia, where the North-pole map needs {{REC_AE_SOUTH}} km/s.</p>
<p><b>Ground transmitters timed to the station</b> can keep in step with it, but a transmitter sends one frequency to every receiver in range. At {{BURST_UTC}}, {{BURST_N}} receivers from Ireland to Belarus were recording the station's UHF signal at the same instant: {{BURST_UP}} heard it shifted up and {{BURST_DOWN}} down, from {{BURST_MIN}} to {{BURST_MAX}} kHz, each within about {{SN_OFFSET}} Hz of the shift the published orbit predicts for its own position. Imitating that takes a separate transmitter for each receiver, heard by no other. The signal also arrives from where the transmitter is, which a directional antenna or a pair of antennas measures, not from the station's place in the sky.</p>
<p><b>The station adjusting its own signal</b> over a flat map: the signal then arrives from the right direction, and the station can give one receiver the shift the globe predicts, but every other receiver hears the correction meant for that one. Over the North-pole map, adjusted for VK4JBE in Brisbane two minutes before closest approach on its highest pass in this window, it is {{ONE_E100}} Hz off for a receiver 100 km east and {{ONE_N1000}} kHz off 1,000 km north (437.55 MHz). Unadjusted, its shift would show the map speed: {{REC_AE_SOUTH}} km/s where the recordings from South America and Australia show {{REC_V_SOUTH}} km/s, and {{REC_AE_EU}} km/s where those from Europe show {{REC_V_EU}} km/s.</p>
<h2>Over {{N_ORBITS}} orbits</h2>
<div class="tw">
<table>
<thead><tr><th scope="col"></th><th scope="col">Globe</th><th scope="col">North-pole AE</th><th scope="col">Dual-pole AE</th><th scope="col">Mercator</th></tr></thead>
<tbody>
<tr><th scope="row">Map speed, km/s</th><td>{{G_VR}}</td><td>{{AE_VR}}</td><td>{{DP_VR}}</td><td>{{ME_VR}}</td></tr>
<tr><th scope="row">Fastest ÷ slowest, each orbit</th><td>{{G_RATIO}}</td><td>{{AE_RATIO}}</td><td>{{DP_RATIO}}</td><td>{{ME_RATIO}}</td></tr>
<tr><th scope="row">Speed gained and lost per orbit, km/s</th><td>{{G_SWING}}</td><td>{{AE_SWING}}</td><td>{{DP_SWING}}</td><td>{{ME_SWING}}</td></tr>
<tr><th scope="row">Largest speed change along the track, g</th><td>{{G_AMAX}}</td><td>{{AE_AMAX}}</td><td>{{DP_AMAX}}</td><td>{{ME_AMAX}}</td></tr>
<tr><th scope="row">Station's track per orbit, map km</th><td>{{G_TRACK}}</td><td>{{AE_TRACK}}</td><td>{{DP_TRACK}}</td><td>{{ME_TRACK}}</td></tr>
<tr><th scope="row">Signal's distance per orbit, map km</th><td>{{G_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td></tr>
<tr><th scope="row">Lag one orbit after lining up, any start</th><td>level</td><td>{{AE_LAG1}}</td><td>{{DP_LAG1}}</td><td>{{ME_LAG1}}</td></tr>
<tr><th scope="row">Gap one orbit after lining up, map km</th><td>{{GLOBE_DRIFT}}</td><td>{{AE_GAP1}}</td><td>{{DP_GAP1}}</td><td>{{ME_GAP1}}</td></tr>
<tr><th scope="row">Largest lag, restarted every minute</th><td>level</td><td>{{AE_LAG60}}</td><td>{{DP_LAG60}}</td><td>{{ME_LAG60}}</td></tr>
</tbody>
</table>
</div>
<h2>Method</h2>
<dl class="method">
<dt>Orbit</dt><dd>ISS (NORAD 25544) orbital elements from CelesTrak, epoch {{EPOCH_UTC}}; positions every second by SGP4 for {{N_ORBITS}} orbits from {{START_UTC}}. Period {{PERIOD}} min, mean altitude {{ALT}} km.</dd>
<dt>Measured speed</dt><dd>{{V_SIG}} km/s: the straight-line reading of the SatNOGS recordings, from the height of each S-curve with no map assumed (weighted mean of {{SN_N}} receivers). Over a plane this is the speed of a source in level flight and of the point beneath it; climbing or descending would make the source faster than the point beneath it, so level flight is the flat maps' best case. On the globe the same recordings fit the published orbit: {{V_ORBIT_RANGE}} km/s relative to the ground at {{ALT}} km, and {{GLOBE_V_RANGE}} km/s for the point beneath.</dd>
<dt>Map speed</dt><dd>Speed of the point beneath the station across the map, from positions 1 s apart, subdivided where a step exceeds 20 km on the map. Speed change: the rate of change of that speed over 10 s, along the track; turning adds to it. The ratio of fastest to slowest does not depend on the map's scale.</dd>
<dt>Signal</dt><dd>A source moving along the station's track on the map at {{V_SIG}} km/s from a moment when it is level with the station: each northbound equator crossing, or the moment chosen with <i>Line up signal now</i>, after which it runs without restarts. With <i>Real-time correction</i> the signal is instead placed where the station was the chosen latency earlier, so it moves at the map speed while its Doppler shift still shows {{V_SIG}} km/s. Signal speed: its speed across the map against the speed its Doppler shift shows. Lag: time since the station was at the signal's position. Gap: distance between them along the track, on the map. The one-orbit figures cover every start time at 1-min intervals over the {{N_ORBITS}} orbits.</dd>
<dt>In view</dt><dd>Within {{HORIZON_DEG}}° of arc ({{HORIZON_KM}} km) of the point beneath the station or the signal: the area over which the station is seen to rise and set, which is the horizon of a sphere at {{ALT}} km. Over a plane nothing would hide it: at {{ALT}} km it would stand {{EL_HORIZON}}° above the horizon at {{HORIZON_KM}} km and {{EL_10000}}° at 10,000 km. Cities: GeoNames, population one million or more, with places within 30 km of a larger one merged ({{N_CITIES}}).</dd>
<dt>Maps</dt><dd>As in the <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a>, except that Mercator's east and west edges are joined so that the track continues across 180°. On the dual-pole map the speed has no upper limit near 0°N 180°E, where the map's rim divides the Pacific; this window's closest pass, {{DP_RIM}}° away, needs {{DP_VMAX}} km/s.</dd>
</dl>
<h2>Sources</h2>
<p><a href="https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;FORMAT=TLE">CelesTrak GP elements</a> · <a href="https://pypi.org/project/sgp4/">sgp4</a> (Python) · <a href="https://www.naturalearthdata.com/">Natural Earth</a> 1:110m land · <a href="https://download.geonames.org/export/dump/">GeoNames</a> cities15000 · SatNOGS recordings of 3 October 2026 as analysed in <a href="https://distance.aethercosmology.com/#doppler">The Shape of Distance, Listening to the ISS</a> · <a href="https://spotthestation.nasa.gov/">NASA Spot the Station</a>.</p>
<button id="dl" type="button">Download the data (JSON)</button>
</section>
<footer class="wrap"><a href="https://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> · Related: <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a> · <a href="https://distance.aethercosmology.com">The Shape of Distance</a></footer>
<div id="tip" hidden></div>
<script>/*D3*/</script>
<script>
(() => {
"use strict";
const D = /*DATA*/null;
const RAD = Math.PI / 180, STEP = D.step, N = D.lat.length;
const NODES = D.nodes, NORB = NODES.length - 1, T_END = NODES[NORB];
const RE = 6371;
const VM = D.v_sig; // measured, km/s: level flight over a plane
const VG = D.vg_ref; // globe: the point beneath, from the same orbit
const COSH = Math.cos(D.horizon_deg * RAD);
const nf = new Intl.NumberFormat("en-US");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const C = { station: "#ffffff", signal: "#ff4fd8", track: "#3ee6ff", ok: "#3dffa0", bad: "#ff4d5e",
ocean: "#06101e", land: "#13243b", coast: "rgba(150,200,255,.22)", grat: "rgba(120,180,255,.08)", rim: "rgba(120,180,255,.3)",
city: "rgba(155,173,198,.32)", faint: "#65789a" };
// sample unit vectors
const UX = new Float64Array(N), UY = new Float64Array(N), UZ = new Float64Array(N);
for (let i = 0; i < N; i++) {
const p = D.lat[i] * RAD, l = D.lon[i] * RAD, c = Math.cos(p);
UX[i] = c * Math.cos(l); UY[i] = c * Math.sin(l); UZ[i] = Math.sin(p);
}
const clampT = t => Math.max(0, Math.min((N - 1) * STEP - 1e-6, t));
function uAt(t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
const a = UX[i] + (UX[i + 1] - UX[i]) * f, b = UY[i] + (UY[i + 1] - UY[i]) * f, c = UZ[i] + (UZ[i + 1] - UZ[i]) * f;
const n = Math.hypot(a, b, c);
return [a / n, b / n, c / n];
}
const lonlat = u => [Math.atan2(u[1], u[0]) / RAD, Math.asin(Math.max(-1, Math.min(1, u[2]))) / RAD];
function orbitOf(t) { let k = 0; while (k < NORB - 1 && t >= NODES[k + 1]) k++; return k; }
function at(arr, t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
return arr[i] + (arr[i + 1] - arr[i]) * f;
}
// signal: level with the station at the sync time, then VM km/s along the map track.
// syncT null: each northbound equator crossing; otherwise the moment chosen with "Line up signal now".
const SM = {};
for (const key of ["ae", "dualpole", "mercator"]) SM[key] = Float64Array.from(D.maps[key].S);
let syncT = null, corr = false, latency = 10;
const LATENCY = [0, 1, 2, 5, 10, 20, 30, 60, 120, 300];
function timeAtDistance(arr, km) {
let lo = 0, hi = arr.length - 1;
if (km <= arr[lo]) return 0;
if (km >= arr[hi]) return hi * STEP;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (arr[mid] <= km) lo = mid; else hi = mid; }
return (lo + (km - arr[lo]) / (arr[hi] - arr[lo])) * STEP;
}
function signalAt(key, t, k) {
const arr = SM[key];
if (corr) { // steered to where the station was `latency` s earlier: moves at the map speed
const L = Math.min(latency, t);
return { lag: L, gap: at(arr, t) - at(arr, t - L), v: at(D.maps[key].v, t - L) / 1000 };
}
const ts = syncT === null ? NODES[k] : syncT;
if (t <= ts) return { lag: 0, gap: 0, v: VM };
const target = at(arr, ts) + VM * (t - ts);
return { lag: t - timeAtDistance(arr, target), gap: at(arr, t) - target, v: VM };
}
// cities
const CITY = D.cities, NC = CITY.length;
const CX = new Float64Array(NC), CY = new Float64Array(NC), CZ = new Float64Array(NC);
CITY.forEach((c, j) => { const p = c[1] * RAD, l = c[2] * RAD; CX[j] = Math.cos(p) * Math.cos(l); CY[j] = Math.cos(p) * Math.sin(l); CZ[j] = Math.sin(p); });
const inView = (u, j) => u[0] * CX[j] + u[1] * CY[j] + u[2] * CZ[j] >= COSH;
// orbit tracks as [lon, lat] lists
const trackCache = {};
function track(k) {
if (trackCache[k]) return trackCache[k];
const pts = [lonlat(uAt(NODES[k]))];
for (let i = Math.ceil(NODES[k] / STEP); i * STEP < NODES[k + 1]; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(NODES[k + 1])));
return (trackCache[k] = pts);
}
function segment(t0, t1) {
if (t1 < t0) [t0, t1] = [t1, t0];
const pts = [lonlat(uAt(t0))];
for (let i = Math.ceil(t0 / STEP); i * STEP < t1; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(t1)));
return { type: "LineString", coordinates: pts };
}
// panels
const LAND = { type: "MultiPolygon", coordinates: D.land };
const GRAT = d3.geoGraticule().step([30, 30])();
const EQ = { type: "LineString", coordinates: d3.range(-180, 181, 2).map(l => [l, 0]) };
const SPH = { type: "Sphere" };
const panels = [...document.querySelectorAll(".panel")].map(el => {
const key = el.dataset.key;
const proj = key === "globe" ? d3.geoOrthographic().clipAngle(90)
: key === "ae" ? d3.geoAzimuthalEquidistant().rotate([0, -90]).clipAngle(180 - 1e-3)
: key === "dualpole" ? d3.geoAzimuthalEquidistant().rotate([0, 0]).clipAngle(180 - 1e-3)
: d3.geoMercator();
proj.precision(0.4);
const [cv, sp] = el.querySelectorAll("canvas");
const f = {}; el.querySelectorAll("[data-f]").forEach(d => (f[d.dataset.f] = d));
return { el, key, proj, cv, ctx: cv.getContext("2d"), sp, sctx: sp.getContext("2d"), f, W: 0, bg: null, cxy: null, state: new Uint8Array(NC), spK: -1, spImg: null };
});
let dpr = 1;
function layout() {
dpr = Math.min(2, window.devicePixelRatio || 1);
for (const p of panels) {
const W = p.cv.clientWidth;
if (!W) continue;
p.W = W; p.cv.width = Math.round(W * dpr); p.cv.height = Math.round(W * dpr);
const s = W / 2 - 3;
if (p.key === "globe") p.proj.scale(s).translate([W / 2, W / 2]);
else if (p.key === "mercator") p.proj.scale(W / (2 * Math.PI)).translate([W / 2, W / 2]);
else p.proj.scale(s / Math.PI).translate([W / 2, W / 2]);
if (p.key !== "globe") {
p.bg = document.createElement("canvas"); p.bg.width = p.cv.width; p.bg.height = p.cv.height;
const g = p.bg.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
base(g, p.proj);
p.cxy = CITY.map(c => p.proj([c[2], c[1]]));
g.fillStyle = C.city; g.beginPath();
for (const q of p.cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const sw = p.sp.clientWidth; p.sp.width = Math.round(sw * dpr); p.sp.height = Math.round(54 * dpr); p.spK = -1;
}
dirty = true;
}
function base(g, proj) {
const path = d3.geoPath(proj, g);
g.beginPath(); path(SPH); g.fillStyle = C.ocean; g.fill();
g.beginPath(); path(GRAT); g.strokeStyle = C.grat; g.lineWidth = 0.7; g.stroke();
g.beginPath(); path(EQ); g.strokeStyle = "rgba(120,180,255,.16)"; g.stroke();
g.beginPath(); path(LAND); g.fillStyle = C.land; g.fill(); g.strokeStyle = C.coast; g.lineWidth = 0.6; g.stroke();
g.beginPath(); path(SPH); g.strokeStyle = C.rim; g.lineWidth = 1; g.stroke();
}
// state at time t
function snapshot(t) {
const k = orbitOf(t), us = uAt(t), st = lonlat(us);
const m = {};
for (const key of ["ae", "dualpole", "mercator"]) {
const M = D.maps[key], sg = signalAt(key, t, k);
m[key] = { lag: sg.lag, gap: sg.gap, v: at(M.v, t) / 1000, a: at(M.a, t) / 1000, ug: uAt(t - sg.lag), vs: sg.v };
}
m.globe = { lag: 0, gap: 0, v: at(D.vglobe, t) / 1000, a: at(D.aglobe, t) / 1000, ug: us, vs: at(D.vglobe, t) / 1000 };
return { t, k, us, st, m };
}
function drawPanel(p, S, pulse) {
if (!p.W) return;
const g = p.ctx, W = p.W, proj = p.proj, path = d3.geoPath(proj, g), M = S.m[p.key];
g.setTransform(dpr, 0, 0, dpr, 0, 0);
if (p.key === "globe") {
proj.rotate([-S.st[0], -S.st[1] * 0.6]);
g.clearRect(0, 0, W, W);
base(g, proj);
} else {
g.clearRect(0, 0, W, W);
g.drawImage(p.bg, 0, 0, W, W);
}
const tr = track(S.k);
g.lineJoin = "round"; g.lineCap = "round";
g.beginPath(); path({ type: "LineString", coordinates: tr }); g.strokeStyle = "rgba(62,230,255,.22)"; g.lineWidth = 1; g.stroke();
g.beginPath(); path(segment(NODES[S.k], S.t)); g.strokeStyle = "rgba(62,230,255,.75)"; g.lineWidth = 1.5; g.stroke();
const sg = lonlat(M.ug);
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(segment(S.t - M.lag, S.t)); g.strokeStyle = "rgba(255,79,216,.55)"; g.lineWidth = 3.5; g.stroke();
}
// above-the-horizon circles
const circ = d3.geoCircle().radius(D.horizon_deg).precision(4);
g.beginPath(); path(circ.center(S.st)()); g.fillStyle = "rgba(255,255,255,.035)"; g.fill(); g.strokeStyle = "rgba(255,255,255,.5)"; g.lineWidth = 1; g.stroke();
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(circ.center(sg)()); g.fillStyle = "rgba(255,79,216,.05)"; g.fill(); g.strokeStyle = "rgba(255,79,216,.65)"; g.stroke();
}
// cities
let cxy = p.cxy;
if (p.key === "globe") {
const ctr = [S.st[0], S.st[1] * 0.6];
cxy = p.cxy = CITY.map(c => (d3.geoDistance([c[2], c[1]], ctr) < Math.PI / 2 - 0.01 ? proj([c[2], c[1]]) : null));
g.fillStyle = C.city; g.beginPath();
for (const q of cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const okP = new Path2D(), badP = new Path2D();
for (let j = 0; j < NC; j++) {
const s = inView(S.us, j), h = inView(M.ug, j), st = s && h ? 1 : s || h ? 2 : 0;
p.state[j] = st;
const q = cxy[j];
if (!st || !q) continue;
const P = st === 1 ? okP : badP, r = st === 1 ? 2.2 : 2.6;
P.moveTo(q[0] + r, q[1]); P.arc(q[0], q[1], r, 0, 7);
}
g.fillStyle = C.ok; g.fill(okP); g.fillStyle = C.bad; g.fill(badP);
// signal, then station
const qs = proj(S.st), qg = proj(sg);
const vis = ll => p.key !== "globe" || d3.geoDistance(ll, [S.st[0], S.st[1] * 0.6]) < Math.PI / 2;
if (qg && vis(sg)) {
if (!reduce) for (const ph of [pulse, (pulse + 0.5) % 1]) {
g.beginPath(); g.arc(qg[0], qg[1], 7 + 15 * ph, 0, 7); g.strokeStyle = `rgba(255,79,216,${(0.55 * (1 - ph)).toFixed(3)})`; g.lineWidth = 1.2; g.stroke();
}
g.beginPath(); g.arc(qg[0], qg[1], 6.5, 0, 7); g.strokeStyle = C.signal; g.lineWidth = 2.2; g.stroke();
}
if (qs) {
g.save(); g.shadowColor = "rgba(255,255,255,.9)"; g.shadowBlur = 10;
g.beginPath(); g.arc(qs[0], qs[1], 4.2, 0, 7); g.fillStyle = C.station; g.fill(); g.restore();
}
}
function drawSpark(p, S) {
const c = p.sctx, w = p.sp.width / dpr, h = 54, L = 30;
if (!w) return;
const k = S.k, t0 = NODES[k], t1 = NODES[k + 1];
const series = p.key === "globe" ? D.vglobe : D.maps[p.key].v;
if (p.spK !== k) {
// linear scale per panel, 0 to this orbit's top speed
let top = 0;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) top = Math.max(top, series[i] / 1000);
const nice = [10, 15, 20, 30, 50, 100, 150, 200, 300, 500, 700, 1000, 1500];
p.spTop = nice.find(n => n >= top * 1.05) || Math.ceil(top / 500) * 500;
const y = v => h - 3 - v / p.spTop * (h - 9);
const img = document.createElement("canvas"); img.width = p.sp.width; img.height = p.sp.height;
const g = img.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.font = "9.5px JetBrains Mono, monospace"; g.fillStyle = C.faint; g.textBaseline = "middle";
g.fillRect(L, Math.round(y(0)) + 0.5, w - L, 0.6); g.fillText("0", 0, y(0));
g.fillRect(L, Math.round(y(p.spTop)) + 0.5, w - L, 0.6); g.fillText(nf.format(p.spTop), 0, y(p.spTop));
const ref = p.key === "globe" ? VG : VM;
g.setLineDash([3, 3]); g.strokeStyle = "rgba(255,255,255,.55)"; g.lineWidth = 1;
g.beginPath(); g.moveTo(L, y(ref)); g.lineTo(w, y(ref)); g.stroke(); g.setLineDash([]);
const X = t => L + (t - t0) / (t1 - t0) * (w - L);
g.beginPath();
let first = true;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) { const xx = X(i * STEP), yy = y(series[i] / 1000); first ? g.moveTo(xx, yy) : g.lineTo(xx, yy); first = false; }
g.strokeStyle = C.track; g.lineWidth = 1.4; g.stroke();
p.spImg = img; p.spK = k; p.spY = y;
}
c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, p.sp.width, p.sp.height); c.drawImage(p.spImg, 0, 0);
c.setTransform(dpr, 0, 0, dpr, 0, 0);
const x = L + (S.t - t0) / (t1 - t0) * (w - L);
c.fillStyle = "rgba(255,255,255,.35)"; c.fillRect(x - 0.5, 0, 1, h);
c.beginPath(); c.arc(x, p.spY(S.m[p.key].v), 3, 0, 7); c.fillStyle = C.station; c.fill();
}
// readouts
const mmss = s => { s = Math.round(s); return s >= 60 ? `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s` : `${s} s`; };
const fv = v => (v >= 100 ? nf.format(Math.round(v)) : v.toFixed(2));
function speedHTML(v, ref) { // value with its ratio to the measured speed, tinted by tier
const r = v / ref, dev = Math.abs(r - 1), cls = dev <= 0.05 ? "ok" : dev <= 0.25 ? "warn" : "bad";
return `${fv(v)} km/s<span class="pill ${cls}">×${r >= 10 ? Math.round(r) : r.toFixed(2)}</span>`;
}
function readouts(S) {
for (const p of panels) {
const M = S.m[p.key], ref = p.key === "globe" ? VG : VM;
p.f.v.innerHTML = speedHTML(M.v, ref);
p.f.vs.innerHTML = speedHTML(M.vs, ref);
const a = M.a;
p.f.a.textContent = Math.abs(a) < 0.005 ? "0.00 g" : `${a > 0 ? "+" : "−"}${Math.abs(a) >= 100 ? nf.format(Math.round(Math.abs(a))) : Math.abs(a).toFixed(2)} g`;
if (p.key === "globe" || Math.abs(M.lag) < 0.5) p.f.s.textContent = "level with the station";
else p.f.s.textContent = `${mmss(Math.abs(M.lag))} ${M.lag > 0 ? "behind" : "ahead"} · ${nf.format(Math.round(Math.abs(M.gap)))} km`;
let now = 0;
for (let j = 0; j < NC; j++) if (p.state[j] === 2) now++;
p.f.c.textContent = String(now);
}
const d = new Date((D.epoch_unix + S.t) * 1000);
clock.textContent = d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
orbitEl.textContent = `Orbit ${S.k + 1} of ${NORB}`;
if (!dragging) range.value = String(Math.round(S.t));
}
// controls
const play = document.getElementById("play"), icon = document.getElementById("playicon");
const range = document.getElementById("time"), clock = document.getElementById("clock"), orbitEl = document.getElementById("orbit");
range.max = String(Math.floor(T_END));
let t = Math.min(D.t_init, T_END - 1), rate = 60, playing = !reduce, dirty = true, dragging = false, lastRO = 0;
function setPlaying(on) {
playing = on;
icon.setAttribute("d", on ? "M2 1h3.5v12H2zM8.5 1H12v12H8.5z" : "M2.5 1l10 6-10 6z");
play.setAttribute("aria-label", on ? "Pause" : "Play");
dirty = true;
}
setPlaying(playing);
play.addEventListener("click", () => setPlaying(!playing));
document.querySelectorAll(".rates button").forEach(b => b.addEventListener("click", () => {
rate = +b.dataset.rate;
document.querySelectorAll(".rates button").forEach(o => o.setAttribute("aria-pressed", String(o === b)));
}));
range.addEventListener("input", () => { t = +range.value; dirty = true; });
const syncBtn = document.getElementById("sync"), syncAuto = document.getElementById("syncauto"), syncInfo = document.getElementById("syncinfo");
syncBtn.addEventListener("click", () => {
syncT = t; syncAuto.hidden = false;
syncInfo.textContent = "Lined up: " + new Date((D.epoch_unix + t) * 1000).toISOString().slice(11, 19) + " UTC";
dirty = true;
});
const corrBtn = document.getElementById("corr"), latIn = document.getElementById("lat"), latV = document.getElementById("latv"), latWrap = document.getElementById("latwrap");
let syncText = "";
corrBtn.addEventListener("click", () => {
corr = !corr;
corrBtn.setAttribute("aria-pressed", String(corr));
latWrap.hidden = !corr; syncBtn.disabled = corr; syncAuto.disabled = corr;
if (corr) { syncText = syncInfo.textContent; syncInfo.textContent = "Corrected continuously"; }
else syncInfo.textContent = syncText;
dirty = true;
});
latIn.addEventListener("input", () => { latency = LATENCY[+latIn.value]; latV.textContent = latency >= 60 ? `${latency / 60} min` : `${latency} s`; dirty = true; });
syncAuto.addEventListener("click", () => {
syncT = null; syncAuto.hidden = true;
syncInfo.textContent = "Lined up: each northbound equator crossing";
dirty = true;
});
range.addEventListener("pointerdown", () => (dragging = true));
window.addEventListener("pointerup", () => (dragging = false));
document.addEventListener("keydown", e => {
if (e.code === "Space" && !/^(INPUT|BUTTON|SELECT|TEXTAREA|A)$/.test(e.target.tagName)) { e.preventDefault(); setPlaying(!playing); }
});
for (const p of panels) {
const seek = e => {
const r = p.sp.getBoundingClientRect(), k = orbitOf(t);
const f = Math.max(0, Math.min(1, (e.clientX - r.left - 30) / (r.width - 30)));
t = NODES[k] + f * (NODES[k + 1] - NODES[k]) - 1e-3; dirty = true;
};
p.sp.addEventListener("pointerdown", e => { seek(e); p.sp.setPointerCapture(e.pointerId); });
p.sp.addEventListener("pointermove", e => { if (e.buttons) seek(e); });
}
// city tooltip
const tip = document.getElementById("tip");
const STATE = (p, j) => {
if (p.key === "globe") return p.state[j] ? "Station and signal in view" : "Neither in view";
const S = lastS, s = inView(S.us, j), h = inView(S.m[p.key].ug, j);
return s && h ? "Station and signal in view" : s ? "Station in view, no signal" : h ? "Signal, no station in view" : "Neither in view";
};
let lastS = null;
for (const p of panels) {
p.cv.addEventListener("pointermove", e => {
if (!p.cxy || !lastS) return;
const r = p.cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
let best = -1, bd = 81;
p.cxy.forEach((q, j) => { if (q) { const d = (q[0] - x) ** 2 + (q[1] - y) ** 2; if (d < bd) { bd = d; best = j; } } });
if (best < 0) { tip.hidden = true; return; }
tip.innerHTML = `<b>${CITY[best][0]}</b>, ${CITY[best][3]}<span>${STATE(p, best)}</span>`;
tip.hidden = false;
const tw = tip.offsetWidth, th = tip.offsetHeight;
tip.style.left = Math.min(window.innerWidth - tw - 8, e.clientX + 14) + "px";
tip.style.top = Math.max(8, e.clientY - th - 10) + "px";
});
p.cv.addEventListener("pointerleave", () => (tip.hidden = true));
}
// data download
document.getElementById("dl").addEventListener("click", () => {
const { land, ...rest } = D;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([JSON.stringify(rest)], { type: "application/json" }));
a.download = "sync_test_data.json"; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
// loop
let last = performance.now();
function frame(now) {
const dt = Math.min(0.25, (now - last) / 1000); last = now;
if (playing) { t += dt * rate; if (t >= T_END) t -= T_END; dirty = true; }
if (dirty) {
const S = snapshot(t), pulse = (now / 1600) % 1;
lastS = S;
for (const p of panels) { drawPanel(p, S, pulse); drawSpark(p, S); }
if (now - lastRO > 120 || !playing) { readouts(S); lastRO = now; }
dirty = playing;
}
requestAnimationFrame(frame);
}
new ResizeObserver(() => layout()).observe(document.querySelector(".grid"));
layout();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>