$sloprun.dev

Orbit Playground

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A classroom gravity sandbox: one planet, one rule, and a dashed preview curve showing the orbit you would get if you let go right now. Press anywhere on the field and drag out a velocity arrow — eccentricity, periapsis, apoapsis and period are read straight off the state vector, so the shape on screen and the numbers in the panel are always the same conic. Motion runs on symplectic Euler at a step size fixed per satellite, which is what keeps a closed orbit closed: after a simulated day at ×6000 the apoapsis has wandered about 11 km out of 30,000. Try a short tap first (no drag at all launches at exactly circular speed), then drag a little further each time until, somewhere past 141% of circular, the ellipse stops closing and the period reads never.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/orbit-playground" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Orbit Playground — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/orbit-playground

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1391 lines 57.5 KB index.html
demos/orbit-playground/index.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>Orbit Playground — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; satellite hues need more than 1 accent) */
:root {
  --scrim: rgba(246,246,243,.9);
  --shadow: 0 1px 2px rgba(26,29,33,.05);
  --s1: #C05600; --s2: #0E7490; --s3: #6D28D9; --s4: #B4126A; --s5: #1D4ED8;
  --planet: #4A515D; --planet2: #20242B;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --scrim: rgba(20,22,26,.9);
    --shadow: 0 1px 2px rgba(0,0,0,.35);
    --s1: #FFB454; --s2: #45C8DE; --s3: #B79CFF; --s4: #FF86BC; --s5: #7FA9FF;
    --planet: #454C57; --planet2: #21252C;
  }
}
:root[data-theme="dark"] {
  --scrim: rgba(20,22,26,.9);
  --shadow: 0 1px 2px rgba(0,0,0,.35);
  --s1: #FFB454; --s2: #45C8DE; --s3: #B79CFF; --s4: #FF86BC; --s5: #7FA9FF;
  --planet: #454C57; --planet2: #21252C;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1140px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code {
  font-family: var(--font-mono); font-size: .87em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}

.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: stretch; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); align-items: start; } }

.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface);
  min-height: 440px; box-shadow: var(--shadow);
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; min-height: 0; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.hint {
  position: absolute; left: 10px; bottom: 10px; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .45s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }

/* Paused affordance. Deliberately NOT a full-field scrim: pausing is how you
   stop and read an orbit, so the field has to stay completely legible. */
.overlay {
  position: absolute; left: 50%; top: 10px; transform: translateX(-50%);
  max-width: calc(100% - 20px); pointer-events: none;
}
.overlay[hidden] { display: none; }
.overlay .box {
  display: flex; align-items: center; gap: 9px;
  background: var(--scrim); border: 1px solid var(--line);
  border-radius: 999px; padding: 5px 6px 5px 13px; box-shadow: var(--shadow);
}
.overlay p {
  margin: 0; font-family: var(--font-mono); font-size: 11px; line-height: 1.3;
  color: var(--muted); letter-spacing: .01em;
}
.overlay button { pointer-events: auto; flex: none; padding: 5px 12px; font-size: 12.5px; }
@media (max-width: 460px) {
  /* wrap only when the note is too long to sit beside the button */
  .overlay .box { border-radius: 14px; flex-wrap: wrap; justify-content: center; padding: 5px 7px; gap: 7px; }
  .overlay p { text-align: center; }
}

.panel { display: flex; flex-direction: column; gap: 12px; }
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.card h2 .tag { letter-spacing: .04em; text-transform: none; color: var(--muted); font-weight: 400; }

/* classification pill */
.cls {
  display: inline-flex; align-items: center; gap: 7px; width: 100%;
  font-family: var(--font-mono); font-size: 13px; letter-spacing: .06em;
  text-transform: uppercase; padding: 7px 11px; border-radius: 8px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg);
  margin-bottom: 11px;
}
.cls .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
.cls.ok   { color: var(--run);    background: var(--run-soft);    border-color: transparent; }
.cls.warn { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.cls.bad  { color: var(--danger); background: var(--bg);          border-color: var(--danger); }
.cls.live { color: var(--ink); background: var(--bg); border-color: var(--line); }

.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 11px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 16px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat .k {
  font-family: var(--font-mono); font-size: 9px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat .v.warn { color: var(--accent); }
.stat .v.bad  { color: var(--danger); }

.strip {
  display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 12px;
  padding-top: 10px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.strip b { color: var(--ink); font-weight: 500; }

/* satellite list */
.satlist { display: flex; flex-direction: column; gap: 4px; margin-top: 11px; }
.satlist:empty { display: none; }
.satrow {
  display: flex; align-items: center; gap: 8px; width: 100%;
  font-family: var(--font-mono); font-size: 11px; text-align: left;
  padding: 5px 8px; border-radius: 7px; border: 1px solid transparent;
  background: transparent; color: var(--muted); cursor: pointer; line-height: 1.2;
  font-variant-numeric: tabular-nums;
}
.satrow:hover { background: var(--bg); }
.satrow[aria-pressed="true"] { border-color: var(--line); background: var(--bg); color: var(--ink); }
.satrow .chip { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.satrow .nm { flex: none; }
.satrow .cl { flex: 1 1 auto; text-align: right; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.satrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* controls */
.ctl { display: block; margin-bottom: 13px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val {
  font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.35; margin-top: 2px; }

input[type=range] {
  -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.big { font-size: 14px; padding: 9px 18px; }
button.wide { width: 100%; }

.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }

.preview {
  margin: 9px 0 0; font-family: var(--font-mono); font-size: 11px;
  color: var(--muted); line-height: 1.45; font-variant-numeric: tabular-nums;
  display: flex; align-items: baseline; gap: 6px;
}
.preview .arrow { color: var(--line); flex: none; }
.preview .body { min-width: 0; }
.preview b { color: var(--ink); font-weight: 500; }
.preview.warn b { color: var(--accent); }
.preview.bad b { color: var(--danger); }

.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; margin-top: 12px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
  width: 34px; height: 19px; border-radius: 999px; background: var(--line);
  border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
  content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
  border-radius: 50%; background: var(--surface); transition: transform .15s ease;
  box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

footer {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  letter-spacing: .05em; padding-top: 2px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Orbit Playground</h1>
    <p class="lede">
      One planet, one rule: <b>everything falls toward mass</b>. Press anywhere on the field and
      <b>drag out a velocity arrow</b> — the dashed curve shows the orbit you'd get before you let go.
      Release to launch. Too slow and it falls back into the planet; too fast and it never comes back.
      A short tap gives you a perfect circle.
    </p>
  </header>

  <main class="stage">

    <div class="field" id="field">
      <canvas id="c" tabindex="0" aria-label="Orbit field. A planet sits at the centre with satellites orbiting it. Drag with the pointer to launch a satellite. With the keyboard: left and right arrows move the launch point around the planet, up and down arrows change its altitude, plus and minus change launch speed, and Enter launches.">
        An interactive canvas showing satellites orbiting a planet. Your browser does not support canvas.
      </canvas>
      <div class="hint" id="hint">drag anywhere to launch &middot; tap for a circular orbit</div>
      <div class="overlay" id="overlay" hidden>
        <div class="box">
          <p id="overlayNote">paused</p>
          <button class="primary" id="overlayPlay" type="button">&#9654;&nbsp;Play</button>
        </div>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Orbit readout">
        <h2>Readout <span class="tag" id="selTag">satellite 1</span></h2>

        <span class="cls" id="clsPill"><span class="dot"></span><span id="clsText">elliptical</span></span>

        <div class="stats">
          <div class="stat"><span class="v" id="stSpeed">&mdash;</span><span class="k">speed km/s</span></div>
          <div class="stat"><span class="v" id="stAlt">&mdash;</span><span class="k">altitude km</span></div>
          <div class="stat"><span class="v" id="stEcc">&mdash;</span><span class="k">eccentricity</span></div>
          <div class="stat"><span class="v" id="stPeri">&mdash;</span><span class="k">periapsis km</span></div>
          <div class="stat"><span class="v" id="stApo">&mdash;</span><span class="k">apoapsis km</span></div>
          <div class="stat"><span class="v" id="stPer">&mdash;</span><span class="k">period</span></div>
        </div>

        <div class="strip">
          <span>clock <b id="stClock">0m 00s</b></span>
          <span>laps <b id="stLaps">0</b></span>
          <span>in orbit <b id="stCount">1</b></span>
        </div>

        <div class="satlist" id="satList" role="group" aria-label="Satellites in the field"></div>
      </section>

      <section class="card" aria-label="Launch a satellite">
        <h2>Launch</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="alt">Altitude</label><span class="val" id="altV" aria-hidden="true">12,000 km</span>
          </div>
          <input type="range" id="alt" min="400" max="30000" step="200" value="12000">
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="spd">Speed</label><span class="val" id="spdV" aria-hidden="true">100% of circular</span>
          </div>
          <input type="range" id="spd" min="40" max="170" step="1" value="100">
          <span class="desc">100% holds a circle &middot; 141% is escape speed</span>
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="ang">Start angle</label><span class="val" id="angV" aria-hidden="true">200&deg;</span>
          </div>
          <input type="range" id="ang" min="0" max="355" step="5" value="200">
        </div>

        <p class="preview" id="preview"><span class="arrow">&rarr;</span><span class="body" id="previewBody">&mdash;</span></p>

        <div class="row" style="margin-top:10px">
          <button type="button" class="primary wide" id="launchBtn">Launch satellite</button>
        </div>
      </section>

      <section class="card" aria-label="Simulation controls">
        <h2>Controls</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="warp">Time warp</label><span class="val" id="warpV" aria-hidden="true">&times;2000</span>
          </div>
          <input type="range" id="warp" min="100" max="6000" step="100" value="2000">
          <span class="desc" id="warpDesc">1 second here &asymp; 33 minutes up there</span>
        </div>

        <div class="row" style="margin-bottom:7px">
          <button type="button" class="primary" id="playBtn">Pause</button>
          <button type="button" id="resetBtn">Reset</button>
        </div>
        <div class="row">
          <button type="button" id="clearBtn">Clear field</button>
        </div>

        <label class="switch" for="paths">
          <input type="checkbox" id="paths" checked>
          <span class="track" aria-hidden="true"></span>
          <span>Orbit paths</span>
        </label>
        <label class="switch" for="trails">
          <input type="checkbox" id="trails" checked>
          <span class="track" aria-hidden="true"></span>
          <span>Fading trails</span>
        </label>
      </section>

    </div>
  </main>

  <footer>demo &middot; sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

<script>
(function () {
  "use strict";

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  var root = document.documentElement;
  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;

  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      readTheme();
      if (!running) draw();
    }
  });
  if (mqDark) {
    var onScheme = function () { readTheme(); if (!running) draw(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── colour reading ─────────────────────────────────────────────────────── */
  var COL = {
    surface: [255,255,255], bg: [246,246,243], ink: [26,29,33], muted: [90,96,104],
    line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55],
    planet: [58,64,72], planet2: [23,26,31]
  };
  var SAT = [[192,86,0],[14,116,144],[109,40,217],[180,18,106],[29,78,216]];

  function hexToRgb(h) {
    h = String(h).trim();
    if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    var n = parseInt(h, 16);
    if (h.length !== 6 || isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fallback) {
    var v = getComputedStyle(root).getPropertyValue(name);
    return hexToRgb(v) || fallback;
  }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }

  function readTheme() {
    COL.surface = tok("--surface", COL.surface);
    COL.bg      = tok("--bg",      COL.bg);
    COL.ink     = tok("--ink",     COL.ink);
    COL.muted   = tok("--muted",   COL.muted);
    COL.line    = tok("--line",    COL.line);
    COL.accent  = tok("--accent",  COL.accent);
    COL.run     = tok("--run",     COL.run);
    COL.danger  = tok("--danger",  COL.danger);
    COL.planet  = tok("--planet",  COL.planet);
    COL.planet2 = tok("--planet2", COL.planet2);
    SAT[0] = tok("--s1", SAT[0]);
    SAT[1] = tok("--s2", SAT[1]);
    SAT[2] = tok("--s3", SAT[2]);
    SAT[3] = tok("--s4", SAT[3]);
    SAT[4] = tok("--s5", SAT[4]);
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var $ = function (id) { return document.getElementById(id); };
  var field = $("field"), canvas = $("c"), ctx = canvas.getContext("2d");
  var hint = $("hint"), overlay = $("overlay"), overlayPlay = $("overlayPlay"), overlayNote = $("overlayNote");
  var clsPill = $("clsPill"), clsText = $("clsText"), selTag = $("selTag");
  var stSpeed = $("stSpeed"), stAlt = $("stAlt"), stEcc = $("stEcc");
  var stPeri = $("stPeri"), stApo = $("stApo"), stPer = $("stPer");
  var stClock = $("stClock"), stLaps = $("stLaps"), stCount = $("stCount");
  var satList = $("satList");
  var elAlt = $("alt"), elSpd = $("spd"), elAng = $("ang"), elWarp = $("warp");
  var altV = $("altV"), spdV = $("spdV"), angV = $("angV"), warpV = $("warpV"), warpDesc = $("warpDesc");
  var preview = $("preview"), previewBody = $("previewBody");
  var launchBtn = $("launchBtn"), playBtn = $("playBtn"), resetBtn = $("resetBtn"), clearBtn = $("clearBtn");
  var elPaths = $("paths"), elTrails = $("trails"), live = $("live");

  /* ── constants (classroom Newtonian gravity, round numbers) ─────────────── */
  var MU = 398600;          /* gravitational parameter, km^3/s^2 */
  var RP = 6000;            /* planet radius, km */
  var WORLD_H = 72000;      /* km of world mapped to the canvas height */
  var TAU_DRAG = 1500;      /* drag arrow reads as "distance covered in 1500 s" */
  var MAXSAT = 5;          /* one distinct hue each — see SAT[] */
  var KSTEP = 0.0010;       /* sub-step h = KSTEP * (periapsis radius) / (periapsis speed) */
  var MAXSUB = 1200;
  var TRAIL_MAX = 190;      /* committed trail points kept per satellite */
  var TRAIL_MIN = WORLD_H * 0.0035;   /* commit one point every ~250 km of path */
  var TRAIL_MIN2 = TRAIL_MIN * TRAIL_MIN;
  var VMAX = 30;            /* km/s clamp on a hand-drawn launch */
  var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';

  /* ── state ──────────────────────────────────────────────────────────────── */
  var W = 0, H = 0, scale = 1, cx = 0, cy = 0;
  var sats = [], ghosts = [], nextId = 1, selId = 0;
  var clock = 0, running = true, warp = 2000;
  var showPaths = true, showTrails = true;
  var aim = { alt: 12000, pct: 100, ang: 200 };
  var aimHot = false;
  var drag = null;                     /* {kind:'new'|'grab', sat, x, y, vx, vy, moved} */
  var rafId = 0, lastT = 0, uiT = 0;
  var reduced = !!(mqRM && mqRM.matches);

  /* ── formatting ─────────────────────────────────────────────────────────── */
  function fmtInt(v) {
    if (!isFinite(v)) return "∞";
    var n = Math.round(v), s = n < 0 ? "-" : "", a = String(Math.abs(n)), out = "", k = 0;
    for (var i = a.length - 1; i >= 0; i--) { out = a.charAt(i) + out; if (++k % 3 === 0 && i > 0) out = "," + out; }
    return s + out;
  }
  function pad2(n) { return (n < 10 ? "0" : "") + n; }
  function fmtDur(s) {
    if (!isFinite(s) || s <= 0) return "—";
    s = Math.round(s);
    var d = Math.floor(s / 86400); s -= d * 86400;
    var h = Math.floor(s / 3600);  s -= h * 3600;
    var m = Math.floor(s / 60);    s -= m * 60;
    if (d) return d + "d " + h + "h";
    if (h) return h + "h " + pad2(m) + "m";
    return m + "m " + pad2(s) + "s";
  }
  function fmtClock(s) {
    s = Math.max(0, Math.round(s));
    var d = Math.floor(s / 86400); s -= d * 86400;
    var h = Math.floor(s / 3600);  s -= h * 3600;
    var m = Math.floor(s / 60);    s -= m * 60;
    if (d) return d + "d " + h + "h";
    if (h) return h + "h " + pad2(m) + "m";
    return m + "m " + pad2(s) + "s";
  }
  function say(t) { live.textContent = t; }

  /* ── orbital elements from state vector ─────────────────────────────────── */
  function elems(x, y, vx, vy) {
    var r = Math.sqrt(x * x + y * y) || 1e-9;
    var v2 = vx * vx + vy * vy;
    var h = x * vy - y * vx;
    var c = v2 - MU / r, rv = x * vx + y * vy;
    var ex = (c * x - rv * vx) / MU, ey = (c * y - rv * vy) / MU;
    var e = Math.sqrt(ex * ex + ey * ey);
    var p = h * h / MU;
    var eps = v2 / 2 - MU / r;
    var o = {
      r: r, v: Math.sqrt(v2), h: h, e: e, p: p, eps: eps, rv: rv,
      w: (e > 1e-7 ? Math.atan2(ey, ex) : Math.atan2(y, x)),
      a: Infinity, peri: 0, apo: Infinity, T: Infinity, kind: "escape"
    };
    o.peri = p / (1 + e);
    /* e is exactly 1 for a purely radial throw, so admit that degenerate case
       too: it is a flat ellipse with apoapsis 2a and periapsis 0, and the
       energy sign is what actually decides whether it comes back. */
    if (eps < -1e-9 && e < 1 + 1e-9) {
      o.a = -MU / (2 * eps);
      o.apo = o.a * (1 + e);
      o.peri = o.a * (1 - e);
      o.T = 2 * Math.PI * Math.sqrt(o.a * o.a * o.a / MU);
      o.kind = (e < 0.02) ? "circular" : "elliptical";
    }
    /* A closed orbit with its low point inside the planet always hits, whichever
       way it is currently moving — it comes back round. An escape path only hits
       if it is still falling inward (rv < 0); once past the low point it is gone. */
    o.impact = (o.peri < RP) && (o.kind !== "escape" || rv < 0);
    return o;
  }
  function classOf(o) {
    if (!(o.p > 1e-6)) {
      /* a purely radial throw: only leaves for good if it has escape energy
         AND is still climbing; anything else is on its way back down */
      return (o.eps >= 0 && o.rv >= 0)
        ? { text: "escape", cls: "warn" }
        : { text: "straight down", cls: "bad" };
    }
    if (o.impact) return { text: "impact course", cls: "bad" };
    if (o.kind === "escape") return { text: "escape", cls: "warn" };
    if (o.kind === "circular") return { text: "circular", cls: "ok" };
    return { text: "elliptical", cls: "live" };
  }

  /* ── conic sampling (same polar formula for ellipse and hyperbola) ──────── */
  function conic(o, out) {
    out.length = 0;
    if (!(o.p > 1e-6) || !isFinite(o.p)) return out;
    var cw = Math.cos(o.w), sw = Math.sin(o.w);
    var RMAX = WORLD_H * 3.2;
    var lo, hi, n;
    if (o.e < 1) { lo = 0; hi = Math.PI * 2; n = 260; }
    else {
      var nmax = Math.acos(Math.max(-1, Math.min(1, -1 / o.e))) * 0.988;
      lo = -nmax; hi = nmax; n = 200;
    }
    var gap = true;
    for (var i = 0; i <= n; i++) {
      var nu = lo + (hi - lo) * i / n;
      var den = 1 + o.e * Math.cos(nu);
      var r = (den < 1e-6) ? Infinity : o.p / den;
      if (!(r <= RMAX)) { gap = true; continue; }
      /* NaN marks a break: without it a huge ellipse whose apoapsis falls off
         the map would be "closed" with a straight chord across the gap */
      if (gap && out.length) out.push(NaN, NaN);
      gap = false;
      var px = r * Math.cos(nu), py = r * Math.sin(nu);
      out.push(px * cw - py * sw, px * sw + py * cw);
    }
    return out;
  }
  var conicBuf = [];

  /* ── satellites ─────────────────────────────────────────────────────────── */
  function makeSat(x, y, vx, vy) {
    var o = elems(x, y, vx, vy);
    return {
      id: nextId++, x: x, y: y, vx: vx, vy: vy,
      trail: [], laps: 0, sweep: 0, ang: Math.atan2(y, x),
      born: clock, o: o, h: chooseStep(o)
    };
  }

  /* Symplectic Euler only conserves energy if the step size never changes, so
     each satellite gets ONE step size, fixed for its whole life: fine enough to
     resolve its fastest moment (periapsis) and then never touched again. */
  function chooseStep(o) {
    var rp = Math.max(o.peri, RP * 0.85);
    var inv = isFinite(o.a) ? 1 / o.a : 0;
    var vp = Math.sqrt(Math.max(MU * (2 / rp - inv), 1e-6));
    var h = KSTEP * rp / vp;
    if (h < 0.05) h = 0.05;
    if (h > 30) h = 30;
    return h;
  }
  function satColor(s) { return SAT[(s.id - 1) % SAT.length]; }
  function selected() {
    for (var i = 0; i < sats.length; i++) if (sats[i].id === selId) return sats[i];
    return null;
  }
  function addSat(s, quiet) {
    if (sats.length >= MAXSAT) return null;
    sats.push(s); selId = s.id;
    fillTrail(s);
    renderList(); syncButtons();
    if (!quiet) {
      var c = classOf(s.o);
      say("Satellite " + s.id + " launched. " + c.text +
          (s.o.impact ? ", it will hit the surface."
           : s.o.kind === "escape" ? ", it will not come back."
           : ". Period " + fmtDur(s.o.T) + "."));
    }
    return s;
  }
  function removeSat(s, why) {
    var i = sats.indexOf(s);
    if (i < 0) return;
    sats.splice(i, 1);
    if (s.trail.length > 3) ghosts.push({ trail: s.trail, col: satColor(s), fade: 1 });
    if (selId === s.id) selId = sats.length ? sats[sats.length - 1].id : 0;
    renderList(); syncButtons();
    if (why) say("Satellite " + s.id + " " + why + ".");
  }

  /* pre-fill a trail along the orbit behind the satellite, so a fresh field
     already reads as motion (and a paused first frame still looks alive) */
  function fillTrail(s) {
    var o = s.o;
    s.trail.length = 0;
    if (o.kind === "escape" || !(o.p > 0)) return;
    var nu0 = Math.atan2(s.y, s.x) - o.w;
    var dir = (o.h >= 0) ? -1 : 1;              /* walk backwards along motion */
    var step = 0.004, minD2 = TRAIL_MIN2;
    var lx = s.x, ly = s.y, pts = [];
    for (var k = 1; k * step < Math.PI * 2 && pts.length < TRAIL_MAX * 2; k++) {
      var nu = nu0 + dir * k * step;
      var den = 1 + o.e * Math.cos(nu);
      if (den < 1e-6) break;
      var r = o.p / den;
      if (r < RP) break;
      var px = r * Math.cos(nu + o.w), py = r * Math.sin(nu + o.w);
      var dx = px - lx, dy = py - ly;
      if (dx * dx + dy * dy >= minD2) { pts.push(px, py); lx = px; ly = py; }
    }
    for (var i = pts.length - 2; i >= 0; i -= 2) s.trail.push(pts[i], pts[i + 1]);
  }

  /* Commit a point only once the satellite has travelled far enough for the
     spacing to matter. The live position is NOT stored — it is drawn as the
     head segment every frame, so the trail always reaches the satellite
     without the committed points being dragged along behind it. */
  function pushTrail(s) {
    var t = s.trail, n = t.length;
    if (n >= 2) {
      var dx = s.x - t[n - 2], dy = s.y - t[n - 1];
      if (dx * dx + dy * dy < TRAIL_MIN2) return;
    }
    t.push(s.x, s.y);
    if (t.length > TRAIL_MAX * 2) t.splice(0, t.length - TRAIL_MAX * 2);
  }

  /* ── physics: symplectic Euler with adaptive sub-steps ──────────────────── */
  function stepSat(s, dt) {
    var h = s.h, left = dt, guard = 0;
    if (dt / h > MAXSUB) h = dt / MAXSUB;     /* safety valve, extreme warp only */
    while (left > 1e-9 && guard++ < MAXSUB + 2) {
      var step = (left > h) ? h : left;
      var r2 = s.x * s.x + s.y * s.y;
      var r = Math.sqrt(r2) || 1e-9;
      var g = -MU / (r2 * r);
      s.vx += g * s.x * step;                 /* velocity first ... */
      s.vy += g * s.y * step;
      s.x  += s.vx * step;                    /* ... then position: symplectic */
      s.y  += s.vy * step;
      left -= step;
      if (s.x * s.x + s.y * s.y < RP * RP) return "reached the surface";
    }
    var rr = Math.sqrt(s.x * s.x + s.y * s.y);
    if (rr > WORLD_H * 3) return "left the neighbourhood";
    return null;
  }

  function tick(dt) {
    clock += dt;
    for (var i = sats.length - 1; i >= 0; i--) {
      var s = sats[i];
      var end = stepSat(s, dt);
      var a = Math.atan2(s.y, s.x), d = a - s.ang;
      while (d > Math.PI) d -= Math.PI * 2;
      while (d < -Math.PI) d += Math.PI * 2;
      s.sweep += Math.abs(d); s.ang = a;
      s.laps = Math.floor(s.sweep / (Math.PI * 2));
      s.o = elems(s.x, s.y, s.vx, s.vy);
      pushTrail(s);
      if (end) removeSat(s, end);
    }
    for (var g = ghosts.length - 1; g >= 0; g--) {
      ghosts[g].fade -= dt / (warp * 1.6);
      if (ghosts[g].fade <= 0) ghosts.splice(g, 1);
    }
  }

  /* ── geometry helpers ───────────────────────────────────────────────────── */
  function sxOf(x) { return cx + x * scale; }
  function syOf(y) { return cy - y * scale; }
  function toWorld(px, py) { return { x: (px - cx) / scale, y: (cy - py) / scale }; }

  function aimState() {
    var r = RP + aim.alt;
    var t = aim.ang * Math.PI / 180;
    var x = r * Math.cos(t), y = r * Math.sin(t);
    var vc = Math.sqrt(MU / r) * (aim.pct / 100);
    return { x: x, y: y, vx: -Math.sin(t) * vc, vy: Math.cos(t) * vc };
  }

  /* ── drawing ────────────────────────────────────────────────────────────── */
  function polyPath(pts) {
    var pen = false, drew = false;
    ctx.beginPath();
    for (var i = 0; i < pts.length; i += 2) {
      var x = pts[i];
      if (x !== x) { pen = false; continue; }        /* NaN break marker */
      var sx = sxOf(x), sy = syOf(pts[i + 1]);
      if (pen) { ctx.lineTo(sx, sy); drew = true; }
      else { ctx.moveTo(sx, sy); pen = true; }
    }
    return drew;
  }

  /* hx,hy = the satellite's live position, appended for the duration of the
     draw so the freshest segment always joins the trail to the dot */
  function drawTrail(t, col, mul, hx, hy) {
    var head = (hx !== undefined);
    if (head) t.push(hx, hy);
    var n = t.length / 2;
    if (n >= 2) {
      var B = 14;
      for (var b = 0; b < B; b++) {
        var i0 = Math.floor(b * (n - 1) / B), i1 = Math.floor((b + 1) * (n - 1) / B);
        if (i1 <= i0) continue;
        var f = (b + 1) / B;
        ctx.beginPath();
        ctx.moveTo(sxOf(t[i0 * 2]), syOf(t[i0 * 2 + 1]));
        for (var i = i0 + 1; i <= i1; i++) ctx.lineTo(sxOf(t[i * 2]), syOf(t[i * 2 + 1]));
        ctx.strokeStyle = rgba(col, (0.05 + 0.55 * f * f) * mul);
        ctx.lineWidth = 1 + 1.6 * f;
        ctx.stroke();
      }
    }
    if (head) t.length -= 2;
  }

  function drawPlanet() {
    var R = RP * scale;
    var glow = ctx.createRadialGradient(cx, cy, R * 0.9, cx, cy, R * 5.5);
    glow.addColorStop(0, rgba(COL.accent, 0.09));
    glow.addColorStop(1, rgba(COL.accent, 0));
    ctx.fillStyle = glow;
    ctx.fillRect(0, 0, W, H);

    var g = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.4, R * 0.1, cx, cy, R * 1.05);
    g.addColorStop(0, rgba(COL.planet, 1));
    g.addColorStop(1, rgba(COL.planet2, 1));
    ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
    ctx.fillStyle = g; ctx.fill();

    ctx.beginPath(); ctx.arc(cx, cy, R + 0.6, 0, Math.PI * 2);
    ctx.strokeStyle = rgba(COL.accent, 0.42); ctx.lineWidth = 1.2; ctx.stroke();
  }

  function drawRings() {
    ctx.save();
    ctx.setLineDash([2, 5]);
    ctx.lineWidth = 1;
    ctx.font = "9px " + MONO;
    ctx.textAlign = "left"; ctx.textBaseline = "middle";
    for (var k = 1; k <= 3; k++) {
      var r = k * 15000;
      var pr = r * scale;
      if (pr > Math.max(W, H) * 0.85) break;
      ctx.beginPath(); ctx.arc(cx, cy, pr, 0, Math.PI * 2);
      ctx.strokeStyle = rgba(COL.muted, 0.22); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = rgba(COL.muted, 0.55);
      var lbl = fmtInt(r) + " km";
      var lw = ctx.measureText(lbl).width;
      var lx = cx + pr * 0.72 + 4, ly = cy - pr * 0.72;
      if (lx + lw > W - 4) lx = W - 4 - lw;
      if (lx < 4) lx = 4;
      if (ly < 8) ly = 8;
      ctx.fillText(lbl, lx, ly);
      ctx.setLineDash([2, 5]);
    }
    ctx.restore();
  }

  function drawApsis(o, col, sel) {
    if (o.kind === "escape" || o.kind === "circular" || !(o.p > 0)) return;
    var pts = [
      { r: o.peri, a: o.w, lbl: "peri" },
      { r: o.apo,  a: o.w + Math.PI, lbl: "apo" }
    ];
    ctx.font = "9px " + MONO;
    ctx.textAlign = "center"; ctx.textBaseline = "middle";
    for (var i = 0; i < pts.length; i++) {
      var p = pts[i];
      if (!isFinite(p.r)) continue;
      var ux = Math.cos(p.a), uy = Math.sin(p.a);
      var x = sxOf(p.r * ux), y = syOf(p.r * uy);
      ctx.beginPath(); ctx.arc(x, y, 3.4, 0, Math.PI * 2);
      ctx.strokeStyle = rgba(col, 0.85); ctx.lineWidth = 1.3; ctx.stroke();
      var lx = x + ux * 13, ly = y - uy * 13;
      var sx0 = sxOf(sel ? sel.x : 1e9), sy0 = syOf(sel ? sel.y : 1e9);
      if ((lx - sx0) * (lx - sx0) + (ly - sy0) * (ly - sy0) > 15 * 15) {
        ctx.fillStyle = rgba(COL.muted, 0.9);
        ctx.fillText(p.lbl, lx, ly);
      }
    }
  }

  function drawArrow(x0, y0, x1, y1, col, wdt) {
    var dx = x1 - x0, dy = y1 - y0, len = Math.sqrt(dx * dx + dy * dy);
    if (len < 2) return;
    var ux = dx / len, uy = dy / len;
    var hx = x1 - ux * 9, hy = y1 - uy * 9;
    ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(hx, hy);
    ctx.strokeStyle = col; ctx.lineWidth = wdt; ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(hx - uy * 4.6, hy + ux * 4.6);
    ctx.lineTo(hx + uy * 4.6, hy - ux * 4.6);
    ctx.closePath();
    ctx.fillStyle = col; ctx.fill();
  }

  function drawGhost() {
    /* the pending launch: dashed conic + start marker, brighter when focused */
    var st, alpha, showArrow = false, label = null;
    if (drag) {
      st = { x: drag.x, y: drag.y, vx: drag.vx, vy: drag.vy };
      alpha = 1; showArrow = true;
    } else {
      if (sats.length >= MAXSAT) return;
      st = aimState();
      alpha = aimHot ? 0.85 : 0.42;
    }
    var o = elems(st.x, st.y, st.vx, st.vy);
    var c = classOf(o);
    /* the pending launch reads in accent so it never gets lost among the
       muted range rings; danger when the curve ends in the surface */
    var col = (c.cls === "bad") ? COL.danger : COL.accent;

    ctx.save();
    ctx.setLineDash([5, 5]);
    ctx.lineWidth = 1.4;
    ctx.strokeStyle = rgba(col, 0.75 * alpha);
    if (polyPath(conic(o, conicBuf))) ctx.stroke();
    ctx.restore();

    var px = sxOf(st.x), py = syOf(st.y);

    if (showArrow) {
      ctx.save();
      ctx.setLineDash([3, 4]);
      ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(px, py);
      ctx.strokeStyle = rgba(COL.muted, 0.4); ctx.lineWidth = 1; ctx.stroke();
      ctx.restore();
      drawArrow(px, py, sxOf(st.x + st.vx * TAU_DRAG), syOf(st.y + st.vy * TAU_DRAG), rgba(col, 1), 2);
      label = o.v.toFixed(2) + " km/s · " + c.text;
    }

    ctx.beginPath(); ctx.arc(px, py, 4.5, 0, Math.PI * 2);
    ctx.fillStyle = rgba(COL.surface, alpha);
    ctx.fill();
    ctx.strokeStyle = rgba(col, alpha); ctx.lineWidth = 1.6; ctx.stroke();

    if (label) {
      ctx.font = "11px " + MONO;
      ctx.textAlign = "left"; ctx.textBaseline = "middle";
      var tw = ctx.measureText(label).width;
      var lx = px + 12, ly = py - 16;
      if (lx + tw + 10 > W) lx = px - tw - 12;
      if (lx < 6) lx = 6;
      if (ly < 12) ly = py + 18;
      ctx.fillStyle = rgba(COL.surface, 0.88);
      ctx.fillRect(lx - 5, ly - 9, tw + 10, 18);
      ctx.strokeStyle = rgba(COL.line, 1); ctx.lineWidth = 1;
      ctx.strokeRect(lx - 5.5, ly - 9.5, tw + 11, 19);
      ctx.fillStyle = rgba(col, 1);
      ctx.fillText(label, lx, ly);
    }
  }

  function draw() {
    if (!W || !H) return;
    ctx.fillStyle = rgba(COL.surface, 1);
    ctx.fillRect(0, 0, W, H);
    ctx.lineJoin = "round"; ctx.lineCap = "round";

    drawRings();
    drawPlanet();

    var i, s, col;

    for (i = 0; i < ghosts.length; i++) {
      if (showTrails) drawTrail(ghosts[i].trail, ghosts[i].col, ghosts[i].fade * 0.6);
    }

    if (showPaths) {
      ctx.save();
      for (i = 0; i < sats.length; i++) {
        s = sats[i]; col = satColor(s);
        ctx.setLineDash(s.o.kind === "escape" ? [6, 5] : []);
        ctx.lineWidth = 1;
        ctx.strokeStyle = rgba(col, s.id === selId ? 0.42 : 0.2);
        if (polyPath(conic(s.o, conicBuf))) ctx.stroke();
      }
      ctx.restore();
    }

    for (i = 0; i < sats.length; i++) {
      s = sats[i]; col = satColor(s);
      if (showTrails) drawTrail(s.trail, col, 1, s.x, s.y);
    }

    var sel = selected();
    if (sel && showPaths) drawApsis(sel.o, satColor(sel), sel);

    for (i = 0; i < sats.length; i++) {
      s = sats[i]; col = satColor(s);
      var x = sxOf(s.x), y = syOf(s.y);
      if (s.id === selId) {
        ctx.beginPath(); ctx.arc(x, y, 9, 0, Math.PI * 2);
        ctx.strokeStyle = rgba(col, 0.5); ctx.lineWidth = 1.2; ctx.stroke();
      }
      ctx.beginPath(); ctx.arc(x, y, 4.6, 0, Math.PI * 2);
      ctx.fillStyle = rgba(col, 1); ctx.fill();
      ctx.strokeStyle = rgba(COL.surface, 0.95); ctx.lineWidth = 1.4; ctx.stroke();
    }

    drawGhost();

    if (!sats.length && !drag) {
      ctx.font = "12px " + MONO;
      ctx.textAlign = "center"; ctx.textBaseline = "middle";
      ctx.fillStyle = rgba(COL.muted, 0.8);
      ctx.fillText("empty field — drag to launch, or press Reset", cx, H - 26);
    }
  }

  /* ── readout ────────────────────────────────────────────────────────────── */
  function setCls(el, textEl, c) {
    el.className = "cls " + c.cls;
    textEl.textContent = c.text;
  }

  function syncPressed() {
    var rows = satList.children;
    for (var i = 0; i < rows.length; i++) {
      rows[i].setAttribute("aria-pressed",
        (+rows[i].getAttribute("data-id") === selId) ? "true" : "false");
    }
  }

  function renderList() {
    satList.textContent = "";
    if (sats.length < 2) return;
    for (var i = 0; i < sats.length; i++) {
      var s = sats[i], col = satColor(s);
      var b = document.createElement("button");
      b.type = "button"; b.className = "satrow";
      b.setAttribute("aria-pressed", s.id === selId ? "true" : "false");
      b.setAttribute("data-id", String(s.id));
      var chip = document.createElement("span");
      chip.className = "chip";
      chip.style.background = rgba(col, 1);
      var nm = document.createElement("span");
      nm.className = "nm"; nm.textContent = "sat " + s.id;
      var cl = document.createElement("span");
      cl.className = "cl"; cl.textContent = classOf(s.o).text;
      b.appendChild(chip); b.appendChild(nm); b.appendChild(cl);
      satList.appendChild(b);
    }
  }

  function updateReadout() {
    var s = selected(), o = null, tag = "", laps = 0;
    /* a grabbed satellite is lifted out of `sats` for the duration of the drag,
       so add it back for the count */
    var held = (drag && drag.kind === "grab") ? 1 : 0;
    stClock.textContent = fmtClock(clock);
    stCount.textContent = String(sats.length + held);

    if (drag) {
      /* while aiming, the readout follows the pending orbit — that is the
         thing the visitor is actually steering, and going blank here would
         hide the numbers exactly when they matter most */
      o = elems(drag.x, drag.y, drag.vx, drag.vy);
      tag = (drag.kind === "grab" && drag.sat)
              ? "satellite " + drag.sat.id + " · aiming"
              : "new satellite · aiming";
      laps = (drag.kind === "grab" && drag.sat) ? drag.sat.laps : 0;
    } else if (s) {
      o = s.o; tag = "satellite " + s.id; laps = s.laps;
    }

    if (!o) {
      selTag.textContent = "no satellite";
      setCls(clsPill, clsText, { text: "empty field", cls: "live" });
      stSpeed.textContent = stAlt.textContent = stEcc.textContent = "—";
      stPeri.textContent = stApo.textContent = stPer.textContent = "—";
      stPeri.className = stApo.className = stPer.className = "v";
      stLaps.textContent = "0";
      return;
    }
    var c = classOf(o);
    selTag.textContent = tag;
    setCls(clsPill, clsText, c);
    stSpeed.textContent = o.v.toFixed(2);
    stAlt.textContent = fmtInt(o.r - RP);
    stEcc.textContent = o.e.toFixed(3);
    stPeri.textContent = fmtInt(o.peri - RP);
    stPeri.className = "v" + (o.impact ? " bad" : "");
    stApo.textContent = (o.kind === "escape") ? "—" : fmtInt(o.apo - RP);
    stApo.className = "v" + (o.kind === "escape" ? " warn" : "");
    stPer.textContent = (o.kind === "escape") ? "never" : fmtDur(o.T);
    stPer.className = "v" + (o.kind === "escape" ? " warn" : "");
    stLaps.textContent = String(laps);

    var rows = satList.children;
    for (var i = 0; i < rows.length; i++) {
      var id = +rows[i].getAttribute("data-id");
      rows[i].setAttribute("aria-pressed", id === selId ? "true" : "false");
      for (var k = 0; k < sats.length; k++) {
        if (sats[k].id === id) rows[i].lastChild.textContent = classOf(sats[k].o).text;
      }
    }
  }

  function updatePreview() {
    var st = aimState();
    var o = elems(st.x, st.y, st.vx, st.vy);
    var c = classOf(o);
    var txt;
    if (o.impact) txt = "<b>impact course</b> — periapsis is " + fmtInt(RP - o.peri) + " km below the surface";
    else if (o.kind === "escape") txt = "<b>escape</b> — it never comes back";
    else if (o.kind === "circular") txt = "<b>circular</b> · " + fmtInt(o.r - RP) + " km up · one lap every " + fmtDur(o.T);
    else txt = "<b>elliptical</b> · peri " + fmtInt(o.peri - RP) + " · apo " + fmtInt(o.apo - RP) + " km · " + fmtDur(o.T);
    previewBody.innerHTML = txt;
    preview.className = "preview " + c.cls;
  }

  function syncButtons() {
    var full = sats.length >= MAXSAT;
    launchBtn.disabled = full;
    launchBtn.textContent = full ? "Field is full (" + MAXSAT + ")" : "Launch satellite";
    clearBtn.disabled = sats.length === 0;
  }

  /* ── sizing ─────────────────────────────────────────────────────────────── */
  function resize() {
    /* clientWidth/Height is the content box — exactly the canvas's CSS size,
       whereas getBoundingClientRect() would include the field's 1px border */
    var cw = Math.max(160, field.clientWidth);
    var ch = Math.max(160, field.clientHeight);
    var dpr = Math.min(2, window.devicePixelRatio || 1);
    if (cw === W && ch === H && canvas.width) return;
    canvas.width = Math.round(cw * dpr);
    canvas.height = Math.round(ch * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    W = cw; H = ch; cx = W / 2; cy = H / 2;
    scale = Math.min(W, H) / WORLD_H;
    draw();
  }

  /* ── set-up / reset ─────────────────────────────────────────────────────── */
  function preload() {
    drag = null;                      /* drop anything held mid-drag */
    sats.length = 0; ghosts.length = 0; nextId = 1; selId = 0; clock = 0;
    /* a pleasing ellipse: 3,000 km up at its lowest, 24,000 km at its highest.
       Placed a little past periapsis rather than exactly on it, so the very
       first frame already shows a satellite mid-climb with its trail behind. */
    var rp = 9000, ra = 30000;
    var a = (rp + ra) / 2;
    var e = (ra - rp) / (ra + rp);
    var pl = a * (1 - e * e);
    var w = -0.55;                       /* which way the low point faces */
    var nu = 1.6;                        /* ~92 degrees past the low point */
    var r = pl / (1 + e * Math.cos(nu));
    var th = nu + w;
    var hh = Math.sqrt(MU * pl);
    var vr = MU / hh * e * Math.sin(nu); /* radial and transverse components */
    var vt = hh / r;
    var s = makeSat(
      r * Math.cos(th), r * Math.sin(th),
      vr * Math.cos(th) - vt * Math.sin(th),
      vr * Math.sin(th) + vt * Math.cos(th)
    );
    addSat(s, true);
  }

  function reset() {
    preload();
    aim.alt = 12000; aim.pct = 100; aim.ang = 200;
    elAlt.value = "12000"; elSpd.value = "100"; elAng.value = "200";
    syncAim();
    updateReadout(); syncButtons(); draw();
    say("Field reset to one elliptical orbit.");
  }

  /* ── controls ───────────────────────────────────────────────────────────── */
  function syncAim() {
    altV.textContent = fmtInt(aim.alt) + " km";
    spdV.textContent = aim.pct + "% of circular";
    angV.textContent = aim.ang + "°";
    elAlt.setAttribute("aria-valuetext", fmtInt(aim.alt) + " kilometres");
    elSpd.setAttribute("aria-valuetext", aim.pct + " percent of circular speed");
    elAng.setAttribute("aria-valuetext", aim.ang + " degrees");
    updatePreview();
  }

  elAlt.addEventListener("input", function () { aim.alt = +elAlt.value; syncAim(); if (!running) draw(); });
  elSpd.addEventListener("input", function () { aim.pct = +elSpd.value; syncAim(); if (!running) draw(); });
  elAng.addEventListener("input", function () { aim.ang = +elAng.value; syncAim(); if (!running) draw(); });

  function hot(v) { return function () { aimHot = v; if (!running) draw(); }; }
  [elAlt, elSpd, elAng, launchBtn].forEach(function (el) {
    el.addEventListener("focus", hot(true));
    el.addEventListener("blur", hot(false));
    el.addEventListener("pointerenter", hot(true));
    el.addEventListener("pointerleave", function () { if (document.activeElement !== elAlt && document.activeElement !== elSpd && document.activeElement !== elAng && document.activeElement !== canvas) { aimHot = false; if (!running) draw(); } });
  });

  elWarp.addEventListener("input", function () {
    warp = +elWarp.value;
    warpV.textContent = "×" + fmtInt(warp);
    elWarp.setAttribute("aria-valuetext", warp + " times real time");
    var mins = warp / 60;
    warpDesc.textContent = "1 second here ≈ " +
      (mins < 60 ? Math.round(mins) + " minutes" : (mins / 60).toFixed(1) + " hours") + " up there";
  });

  function doLaunch() {
    if (sats.length >= MAXSAT) return;
    var st = aimState();
    addSat(makeSat(st.x, st.y, st.vx, st.vy));
    dismissHint();
    updateReadout(); draw();
  }
  launchBtn.addEventListener("click", doLaunch);

  playBtn.addEventListener("click", function () { setRunning(!running, true); });
  overlayPlay.addEventListener("click", function () { setRunning(true, true); focusField(); });
  resetBtn.addEventListener("click", function () { reset(); });
  clearBtn.addEventListener("click", function () {
    drag = null;                      /* drop anything held mid-drag */
    if (!sats.length) return;
    for (var i = sats.length - 1; i >= 0; i--) removeSat(sats[i]);
    ghosts.length = 0;
    say("Field cleared.");
    updateReadout(); draw();
  });

  elPaths.addEventListener("change", function () { showPaths = elPaths.checked; if (!running) draw(); });
  elTrails.addEventListener("change", function () { showTrails = elTrails.checked; if (!running) draw(); });

  satList.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest(".satrow") : null;
    if (!b) return;
    selId = +b.getAttribute("data-id");
    syncPressed(); updateReadout(); draw();
  });

  function setRunning(v, announce) {
    running = v;
    playBtn.textContent = running ? "Pause" : "Play";
    overlay.hidden = running;
    if (announce) {
      if (!running) overlayNote.textContent = "paused";
      say(running ? "Simulation running." : "Simulation paused.");
    }
    if (running) { lastT = 0; if (!rafId) rafId = requestAnimationFrame(loop); }
    else draw();
  }

  function dismissHint() {
    if (hint.classList.contains("gone")) return;
    hint.classList.add("gone");
    setTimeout(function () { hint.style.display = "none"; }, 500);
  }

  /* ── pointer: drag to launch, or grab an existing satellite ─────────────── */
  function evPos(e) {
    var r = canvas.getBoundingClientRect();
    return { px: e.clientX - r.left, py: e.clientY - r.top };
  }
  /* never scroll the page out from under a drag in progress */
  function focusField() {
    try { canvas.focus({ preventScroll: true }); } catch (err) { canvas.focus(); }
  }

  canvas.addEventListener("pointerdown", function (e) {
    if (e.button !== undefined && e.button !== 0) return;
    var p = evPos(e), i, s;
    focusField();

    for (i = 0; i < sats.length; i++) {
      s = sats[i];
      var dx = sxOf(s.x) - p.px, dy = syOf(s.y) - p.py;
      if (dx * dx + dy * dy < 18 * 18) {
        selId = s.id; syncPressed();
        drag = { kind: "grab", sat: s, x: s.x, y: s.y, vx: s.vx, vy: s.vy, moved: false };
        sats.splice(i, 1);
        break;
      }
    }
    if (!drag) {
      if (sats.length >= MAXSAT) { say("Field is full. Clear it before launching another."); return; }
      var w = toWorld(p.px, p.py);
      var r = Math.sqrt(w.x * w.x + w.y * w.y);
      if (r < RP * 1.06) {
        var t = Math.atan2(w.y, w.x) || 0;
        w.x = RP * 1.15 * Math.cos(t); w.y = RP * 1.15 * Math.sin(t);
        r = RP * 1.15;
      }
      var vc = Math.sqrt(MU / r);
      drag = { kind: "new", sat: null, x: w.x, y: w.y,
               vx: -w.y / r * vc, vy: w.x / r * vc, moved: false };
    }
    if (canvas.setPointerCapture) { try { canvas.setPointerCapture(e.pointerId); } catch (err) {} }
    dismissHint();
    e.preventDefault();
    updateReadout();
    draw();
  });

  canvas.addEventListener("pointermove", function (e) {
    if (!drag) return;
    var p = evPos(e);
    var w = toWorld(p.px, p.py);
    var dx = w.x - drag.x, dy = w.y - drag.y;
    var dpx = (dx * scale), dpy = (dy * scale);
    if (Math.sqrt(dpx * dpx + dpy * dpy) > 5) drag.moved = true;
    if (drag.moved) {
      var vx = dx / TAU_DRAG, vy = dy / TAU_DRAG;
      var v = Math.sqrt(vx * vx + vy * vy);
      if (v > VMAX) { vx = vx / v * VMAX; vy = vy / v * VMAX; }
      drag.vx = vx; drag.vy = vy;
    }
    e.preventDefault();
    updateReadout();
    if (!running) draw();
  });

  function endDrag(cancel) {
    if (!drag) return;
    var d = drag; drag = null;
    if (cancel && d.kind === "grab" && d.sat) {
      sats.push(d.sat); renderList(); syncButtons();
    } else if (!cancel) {
      if (d.kind === "grab" && d.sat) {
        d.sat.x = d.x; d.sat.y = d.y; d.sat.vx = d.vx; d.sat.vy = d.vy;
        d.sat.o = elems(d.x, d.y, d.vx, d.vy);
        d.sat.h = chooseStep(d.sat.o);
        d.sat.trail.length = 0; d.sat.sweep = 0; d.sat.laps = 0;
        d.sat.ang = Math.atan2(d.y, d.x);
        sats.push(d.sat); selId = d.sat.id;
        fillTrail(d.sat); renderList(); syncButtons();
        var c0 = classOf(d.sat.o);
        say("Satellite " + d.sat.id + " re-launched. " + c0.text + ".");
      } else {
        addSat(makeSat(d.x, d.y, d.vx, d.vy));
      }
    }
    updateReadout(); draw();
  }

  canvas.addEventListener("pointerup", function (e) { endDrag(false); e.preventDefault(); });
  canvas.addEventListener("pointercancel", function () { endDrag(true); });
  /* safety net: these no-op when the canvas handlers above already ran */
  window.addEventListener("pointerup", function () { if (drag) endDrag(false); });
  window.addEventListener("pointercancel", function () { if (drag) endDrag(true); });

  canvas.addEventListener("keydown", function (e) {
    var k = e.key, fine = e.shiftKey, handled = true;
    if (drag && k === "Escape") { endDrag(true); return; }
    if (k === "ArrowLeft")       aim.ang = (aim.ang - (fine ? 1 : 5) + 360) % 360;
    else if (k === "ArrowRight") aim.ang = (aim.ang + (fine ? 1 : 5)) % 360;
    else if (k === "ArrowUp")    aim.alt = Math.min(30000, aim.alt + (fine ? 200 : 1000));
    else if (k === "ArrowDown")  aim.alt = Math.max(400, aim.alt - (fine ? 200 : 1000));
    else if (k === "+" || k === "=") aim.pct = Math.min(170, aim.pct + (fine ? 1 : 3));
    else if (k === "-" || k === "_") aim.pct = Math.max(40, aim.pct - (fine ? 1 : 3));
    else if (k === "Enter" || k === " " || k === "Spacebar") { doLaunch(); return void e.preventDefault(); }
    else handled = false;
    if (!handled) return;
    e.preventDefault();
    elAlt.value = String(aim.alt); elSpd.value = String(aim.pct); elAng.value = String(aim.ang);
    syncAim();
    dismissHint();
    if (!running) draw();
  });
  canvas.addEventListener("focus", function () { aimHot = true; if (!running) draw(); });
  canvas.addEventListener("blur", function () { aimHot = false; if (drag) endDrag(true); if (!running) draw(); });

  /* ── main loop ──────────────────────────────────────────────────────────── */
  function loop(t) {
    rafId = 0;
    if (!running) return;
    if (!lastT) lastT = t;
    var dtReal = (t - lastT) / 1000;
    lastT = t;
    if (dtReal > 0.05) dtReal = 0.05;
    if (dtReal > 0) tick(dtReal * warp);
    draw();
    if (t - uiT > 110) { uiT = t; updateReadout(); }
    rafId = requestAnimationFrame(loop);
  }

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  if (window.ResizeObserver) new ResizeObserver(resize).observe(field);
  window.addEventListener("resize", resize);
  resize();
  preload();
  syncAim();
  warpV.textContent = "×2,000";
  elWarp.setAttribute("aria-valuetext", "2000 times real time");
  updateReadout();
  syncButtons();
  renderList();

  if (mqRM) {
    var onRM = function () {
      reduced = mqRM.matches;
      if (reduced) { setRunning(false, false); overlayNote.textContent = "paused \u00b7 reduced motion"; }
    };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  if (reduced) {
    overlayNote.textContent = "paused \u00b7 reduced motion";
    setRunning(false, false);
  } else {
    setRunning(true, false);
  }
  draw();

  document.addEventListener("visibilitychange", function () { if (!document.hidden) lastT = 0; });
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong