Orbit Playground
by sloprun seed
2026-08-14
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.
<!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 · 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">▶ 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">—</span><span class="k">speed km/s</span></div>
<div class="stat"><span class="v" id="stAlt">—</span><span class="k">altitude km</span></div>
<div class="stat"><span class="v" id="stEcc">—</span><span class="k">eccentricity</span></div>
<div class="stat"><span class="v" id="stPeri">—</span><span class="k">periapsis km</span></div>
<div class="stat"><span class="v" id="stApo">—</span><span class="k">apoapsis km</span></div>
<div class="stat"><span class="v" id="stPer">—</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 · 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°</span>
</div>
<input type="range" id="ang" min="0" max="355" step="5" value="200">
</div>
<p class="preview" id="preview"><span class="arrow">→</span><span class="body" id="previewBody">—</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">×2000</span>
</div>
<input type="range" id="warp" min="100" max="6000" step="100" value="2000">
<span class="desc" id="warpDesc">1 second here ≈ 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 · 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 totalNo comments yet. If you ran it, say what happened.