sky.papp
All 422 lines. No libraries, nothing hidden. Read it before you run it.
<!doctype html>
<meta charset="utf-8">
<meta name="papp-net" content="celestrak.org:443">
<meta name="papp-write" content="./sky.json">
<meta name="papp-use" content="notifications">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0.9 -0.4 64 64'><path d='M25.4 11.1A25 25 0 1 0 52.2 42.4A21 21 0 0 1 25.4 11.1z' fill='%23111114'/><path d='M16 4Q46 14 61 48' fill='none' stroke='%23111114' stroke-width='3.5' stroke-linecap='round' stroke-dasharray='0 7'/><circle cx='42.3' cy='20' r='6' fill='%23ff4f00'/></svg>">
<!--
Tonight's sky: is the ISS flying over tonight, and when?
Type your latitude and longitude once; they're saved in sky.json, next to this page, and never leave your computer.
Once a day it fetches the ISS's orbit, a TLE, from celestrak.org and keeps it in sky.json too, so it works offline after.
It lists the ISS's passes after dark for tonight and the next three nights, above 10°, and which of them you can see:
the sky is dark and the ISS is still in sunlight. Then the planets from Mercury to Saturn, and the Moon.
Leave the window open and a notification comes 5 minutes before each visible pass.
The math is home-made and simplified. The orbit is the TLE's mean elements, turned by the Earth's flattening (J2)
and slowed by drag, as in SGP4's long-term terms: pass times stay within a few seconds of full SGP4. The TLE itself
goes stale as the air drags on the ISS and it fires its engines, so trust a 3-day-old TLE to a minute or so.
The Sun and planets come from Paul Schlyter's tables, the Moon from the Astronomical Almanac's short series:
rise and set times within about a minute, plus the weather at the horizon.
-->
<title>Tonight's sky</title>
<style>
[hidden] { display: none !important; }
:root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
body { max-width: 760px; margin: 0 auto; padding: 40px 28px; background: var(--paper); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 24px; }
h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
header p { margin: 0; color: var(--soft); }
h2 { display: flex; justify-content: space-between; margin: 36px 0 8px; color: var(--soft); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
h2 span { font-weight: 400; letter-spacing: 0; text-transform: none; }
h3 { margin: 18px 0 2px; font-size: 13px; font-weight: 600; }
button { padding: 5px 12px; border: 1px solid var(--rule); border-radius: 99px; background: none; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
button:hover { border-color: var(--accent); background: var(--accent); color: #0f0f11; }
form { display: grid; gap: 14px; max-width: 420px; padding: 20px; border: 1px solid var(--rule); border-radius: 12px; }
form p { margin: 0; color: var(--soft); }
label { display: grid; gap: 4px; font-weight: 500; }
input { padding: 8px 10px; border: 1px solid var(--rule); border-radius: 8px; background: var(--well); color: var(--ink); font: inherit; }
input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
form button { justify-self: start; }
#next { display: grid; gap: 4px; padding: 16px 18px; border: 1px solid var(--rule); border-left: 4px solid var(--accent); border-radius: 12px; }
#next.none { border-left-color: var(--rule); }
#next small { color: var(--soft); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
#next strong { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; }
#next strong span { margin-left: 10px; color: var(--soft); font-size: 14px; font-weight: 400; letter-spacing: 0; }
#next p { margin: 0; }
#next p:last-child { color: var(--soft); }
.row { display: grid; grid-template-columns: 4rem 7.5rem 1fr 4rem 10rem; gap: 14px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--rule); }
.row.planet { grid-template-columns: 6rem 4rem 4rem 1fr 5rem; }
.row.moon { grid-template-columns: 1.6rem 1fr 4rem 4rem; }
.row > :last-child { text-align: right; }
.head { padding: 0 0 4px; color: var(--soft); font-size: 12px; }
.soft, .row.faint { color: var(--soft); }
.high { display: flex; gap: 8px; align-items: center; }
.track { width: 72px; height: 6px; border-radius: 99px; background: var(--well); }
.fill { display: block; height: 100%; border-radius: inherit; background: var(--soft); }
.visible .fill { background: var(--accent); }
.visible > :last-child::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: var(--accent); }
.note { margin: 8px 0; padding: 14px 16px; border-radius: 12px; background: var(--well); color: var(--soft); }
.note button { margin-left: 8px; }
footer { margin-top: 36px; color: var(--soft); font-size: 12px; }
</style>
<header><h1>Tonight’s sky</h1><p><span id="where"></span> <button id="change" hidden>Change</button></p></header>
<form id="setup" hidden>
<p>Where do you watch from? Right-click a spot in any map app to copy its latitude and longitude.</p>
<label>Latitude, longitude <input id="coords" placeholder="51.4779, -0.0015" autocomplete="off" required></label>
<label>Name, if you like <input id="label" placeholder="Home" autocomplete="off"></label>
<p id="problem"></p>
<button>Show my sky</button>
</form>
<main id="sky" hidden>
<section id="next"></section>
<h2>ISS passes <span>after dark, above 10°</span></h2>
<div id="passList"></div>
<h2>Planets <span id="darkness"></span></h2>
<div id="planetList"></div>
<h2>Moon</h2>
<div id="moonRow"></div>
<footer id="source"></footer>
</main>
<script type="module">
const url = 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE';
const saved = (await papp.exists('sky.json')) ? JSON.parse(await papp.readFile('sky.json')) : {};
let failure = ''; // why the last fetch failed: 'offline' or what celestrak.org said
let tried = 0; // when it last tried to fetch
let warned = 0; // the pass it last sent a notification for
setup.onsubmit = async event => {
event.preventDefault();
const place = parsePlace(coords.value);
if (!place) {
problem.textContent = 'Two numbers, like 51.48, -0.13: south and west are negative, or end in S or W.';
return;
}
Object.assign(saved, place, { name: label.value.trim() });
await papp.writeFile('sky.json', JSON.stringify(saved, null, 2) + '\n');
setup.hidden = true;
tick();
};
change.onclick = () => {
[coords.value, label.value, problem.textContent] = [`${saved.lat}, ${saved.lon}`, saved.name ?? '', ''];
[setup.hidden, sky.hidden] = [false, true];
};
passList.onclick = ({ target }) => {
if (target.id === 'retry') retry();
};
addEventListener('online', retry);
function retry() {
tried = 0;
tick();
}
// Draws from what it has, then fetches a TLE if today's is missing and draws again.
async function tick() {
if (saved.lat === undefined || !setup.hidden) return;
draw();
const fresh = saved.fetched && new Date(saved.fetched).toDateString() === new Date().toDateString();
if (fresh || Date.now() - tried < 3600e3) return;
tried = Date.now();
try {
const response = await fetch(url);
const lines = (await response.text()).trim().split(/\r?\n/).map(line => line.trimEnd());
if (!response.ok) throw new Error(`celestrak.org answered with an error, ${response.status}.`);
if (!lines[1]?.startsWith('1 25544') || !lines[2]?.startsWith('2 25544')) throw new Error('celestrak.org sent something that isn’t a TLE.');
Object.assign(saved, { tle: lines, fetched: new Date().toISOString() });
await papp.writeFile('sky.json', JSON.stringify(saved, null, 2) + '\n');
failure = '';
} catch (error) {
failure = error instanceof TypeError ? 'offline' : error.message;
}
draw();
}
// "51.48, -0.13" or "51.48 N 0.13 W": north and east are positive.
function parsePlace(text) {
const parts = text.toUpperCase().match(/-?\d+(\.\d+)?\s*°?\s*[NSEW]?/g);
if (parts?.length !== 2) return null;
const [lat, lon] = parts.map(part => parseFloat(part) * (/[SW]/.test(part) ? -1 : 1));
return Math.abs(lat) <= 90 && Math.abs(lon) <= 180 ? { lat, lon } : null;
}
function draw() {
const now = Date.now();
// Tonight runs from noon to noon, so at 2 a.m. it is still last night.
const noon = new Date(now - 432e5).setHours(12, 0, 0, 0);
const night = dark(saved, noon);
const passes = saved.tle ? issPasses(saved, saved.tle, now, noon + 4 * 864e5) : [];
where.textContent = `${saved.name || 'Here'} · ${Math.abs(saved.lat).toFixed(2)}° ${saved.lat < 0 ? 'S' : 'N'}, ${Math.abs(saved.lon).toFixed(2)}° ${saved.lon < 0 ? 'W' : 'E'}`;
[change.hidden, sky.hidden] = [false, false];
const coming = passes.find(pass => pass.seen && pass.seen.to > now);
if (coming && coming.seen.from - 5 * minute <= now && now < coming.seen.from && Math.abs(coming.seen.from - warned) > 10 * minute) {
warned = coming.seen.from;
papp.notify(`The ISS in ${span(coming.seen.from - now)}`, `Look ${compass(coming.seen.az)} at ${clock(coming.seen.from)}. It climbs to ${coming.top.alt.toFixed(0)}°.`);
}
next.className = coming ? '' : 'none';
next.hidden = !saved.tle;
next.innerHTML = coming
? `<small>Next visible pass</small>
<strong>${clock(coming.seen.from)}<span>${nightName(coming.start, noon)} · ${coming.seen.from <= now ? 'now' : `in ${span(coming.seen.from - now)}`}</span></strong>
<p>Look ${compass(coming.seen.az)}. It climbs to ${coming.top.alt.toFixed(0)}° in the ${compass(coming.top.az)} and stays in sight for ${span(coming.seen.to - coming.seen.from)}.</p>
<p>A notification comes 5 minutes before, while this window is open.</p>`
: `<small>No visible pass</small><p>In the next four nights the ISS is in the Earth’s shadow, or below 10°, whenever your sky is dark.</p>`;
if (!saved.tle) passList.innerHTML = failure
? `<p class="note">${failure === 'offline' ? 'Can’t reach celestrak.org, and there’s no saved orbit yet. Planets and Moon work without it.' : failure}<button id="retry">Try again</button></p>`
: '<p class="note">Fetching the ISS’s orbit from celestrak.org…</p>';
else if (!passes.length) passList.innerHTML = '<p class="note">No passes after dark in the next four nights.</p>';
else passList.innerHTML = '<div class="row head"><span>Starts</span><span>Rises → sets</span><span>Highest</span><span>Lasts</span><span></span></div>' + passes.map((pass, index) => `
${index && nightName(pass.start, noon) === nightName(passes[index - 1].start, noon) ? '' : `<h3>${nightName(pass.start, noon)}</h3>`}
<div class="row ${pass.seen ? 'visible' : 'faint'}">
<span>${clock(pass.start)}</span>
<span>${compass(pass.rise)} → ${compass(pass.set)}</span>
<span class="high"><span class="track"><span class="fill" style="width: ${(pass.top.alt / 90) * 100}%"></span></span>${pass.top.alt.toFixed(0)}°</span>
<span>${span(pass.end - pass.start)}</span>
<span>${!pass.seen ? 'In Earth’s shadow' : pass.seen.from > pass.start + minute ? `Visible from ${clock(pass.seen.from)}` : pass.seen.to < pass.end - minute ? `Visible to ${clock(pass.seen.to)}` : 'Visible'}</span>
</div>`).join('');
darkness.textContent = !night ? 'No dark sky tonight' : `Dark ${clock(night.from)} to ${clock(night.to)}`;
planetList.innerHTML = '<div class="row planet head"><span></span><span>Rises</span><span>Sets</span><span>After dark</span><span>Highest</span></div>' + Object.entries(planets).map(([name, table]) => {
const { rise, set, best } = riseSet(saved, noon, night, time => position(table, time), -0.57);
return `<div class="row planet ${best ? '' : 'faint'}"><span>${name}</span><span>${rise}</span><span>${set}</span>
<span>${best ? best.when : 'Not up after dark'}</span><span>${best ? `${best.alt.toFixed(0)}° ${compass(best.az)}` : ''}</span></div>`;
}).join('');
const { rise, set } = riseSet(saved, noon, night, time => moon(time).at, 0.7275 * moon(now).parallax - 0.57);
const { lit, age } = phase(now);
const right = age < 0.5 === saved.lat >= 0; // the lit side, as you see it
moonRow.innerHTML = `<div class="row moon">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="10" fill="#1c1c20" stroke="var(--rule)"/>
<path d="M12 2A10 10 0 0 ${right ? 1 : 0} 12 22A${Math.abs(1 - 2 * lit) * 10} 10 0 0 ${lit > 0.5 === right ? 1 : 0} 12 2" fill="#f2f2f3"/></svg>
<span>${['New Moon', 'Waxing crescent', 'First quarter', 'Waxing gibbous', 'Full Moon', 'Waning gibbous', 'Last quarter', 'Waning crescent'][Math.round(age * 8) % 8]}
<span class="soft"> · ${(lit * 100).toFixed(0)}% lit</span></span>
<span>↑ ${rise}</span><span>↓ ${set}</span></div>`;
const daysOld = saved.tle && (now - satellite(saved.tle).epoch) / 864e5;
const orbit = saved.tle && `the ISS orbit fetched ${new Date(saved.fetched).toLocaleDateString([], { day: 'numeric', month: 'short' })} at ${clock(saved.fetched)}`;
source.textContent = !saved.tle ? 'Your location stays on this computer; only the ISS’s orbit is fetched, from celestrak.org.'
: (failure === 'offline' ? `Offline: using ${orbit}.` : failure ? `${failure} Using ${orbit}.` : `From celestrak.org: ${orbit}.`)
+ (daysOld > 7 ? ` It’s ${daysOld.toFixed(0)} days old, so pass times may be minutes off.` : '')
+ ' Your location stays on this computer.';
}
const clock = time => new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const compass = az => ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'][Math.round(az / 22.5) % 16];
function span(ms) {
const minutes = Math.max(1, Math.round(ms / minute));
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
function nightName(time, noon) {
const nights = Math.floor((time - noon) / 864e5);
return nights === 0 ? 'Tonight' : nights === 1 ? 'Tomorrow night' : new Date(time - 432e5).toLocaleDateString([], { weekday: 'long' }) + ' night';
}
// When the Sun is more than 6° below the horizon: { from, to } within the night, or null.
function dark(place, noon) {
const sunAlt = time => look(place, time, position(sun, time)).alt + 6;
const times = crossings(sunAlt, noon, noon + 864e5, 10 * minute);
const from = times.find(time => !time.rising)?.time ?? (sunAlt(noon) < 0 ? noon : null);
const to = times.find(time => time.rising && time.time > from)?.time ?? noon + 864e5;
return from === null ? null : { from, to };
}
// Rise and set as clock times, and when it's highest after dark, if it's 5° up then.
function riseSet(place, noon, night, at, horizon) {
const alt = time => look(place, time, at(time));
const times = crossings(time => alt(time).alt - horizon, noon, noon + 864e5, 10 * minute);
const [rise, set] = [true, false].map(rising => times.find(time => time.rising === rising));
let best = null;
const up = [];
for (let time = night?.from ?? 0; night && time <= night.to; time += 5 * minute) {
const view = alt(time);
if (view.alt < 5) continue;
up.push(time);
if (!best || view.alt > best.alt) best = view;
}
if (best) {
const [first, last, near] = [up[0], up.at(-1), (a, b) => Math.abs(a - b) <= 5 * minute];
best.when = near(first, night.from) && near(last, night.to) ? 'All night'
: near(first, night.from) ? `Until ${clock(last)}` : near(last, night.to) ? `From ${clock(first)}` : `${clock(first)} to ${clock(last)}`;
}
return { rise: rise ? clock(rise.time) : '–', set: set ? clock(set.time) : '–', best };
}
// Each pass above 10°, kept if the sky is dark at its highest; seen is the part in sunlight.
function issPasses(place, tle, from, to) {
const iss = satellite(tle);
const view = time => {
const [s, o] = [iss(time), observer(place, time)];
return look(place, time, [s[0] - o[0], s[1] - o[1], s[2] - o[2]]);
};
const times = crossings(time => view(time).alt - 10, from, to, 20e3);
if (view(from).alt >= 10) times.unshift({ time: from, rising: true });
const passes = [];
for (const [index, { time: start, rising }] of times.entries()) {
const end = times[index + 1]?.time;
if (!rising || !end) continue;
let top = { alt: -90 };
const lit = [];
for (let time = start; time <= end; time += 5e3) {
const here = view(time);
if (here.alt > top.alt) top = { ...here, time };
if (sunlit(iss(time), position(sun, time))) lit.push(time);
}
if (look(place, top.time, position(sun, top.time)).alt > -6) continue;
const seen = lit.length ? { from: lit[0], to: lit.at(-1), az: view(lit[0]).az } : null;
passes.push({ start, end, top, rise: view(start).az, set: view(end).az, seen });
}
return passes;
}
// In sunlight unless behind the Earth and inside its shadow, taken as a cylinder as wide as the Earth.
function sunlit(r, toSun) {
const unit = toSun.map(value => value / Math.hypot(...toSun));
const along = r[0] * unit[0] + r[1] * unit[1] + r[2] * unit[2];
return along > 0 || Math.hypot(...r.map((value, i) => value - along * unit[i])) > earth;
}
// The sky, from here down: pure math on times in ms and places in degrees.
const rad = Math.PI / 180;
const minute = 6e4;
const earth = 6378.135; // km, the radius SGP4 uses
// Rise and set within [from, to]: step along f, then halve each step where its sign flips.
function crossings(f, from, to, step) {
const found = [];
for (let a = from, fa = f(a); a < to; a += step) {
const b = Math.min(a + step, to);
const fb = f(b);
if (fa < 0 !== fb < 0) {
let [low, high] = [a, b];
for (let i = 0; i < 20; i++) {
const middle = (low + high) / 2;
if (f(middle) < 0 === fa < 0) low = middle;
else high = middle;
}
found.push({ time: (low + high) / 2, rising: fb >= 0 });
}
fa = fb;
}
return found;
}
// Where a body on an ellipse is: a in any unit, angles in radians, x toward the equinox.
function onOrbit(a, e, tilt, node, perigee, anomaly) {
let E = anomaly;
for (let i = 0; i < 8; i++) E -= (E - e * Math.sin(E) - anomaly) / (1 - e * Math.cos(E));
const [x, y] = [a * (Math.cos(E) - e), a * Math.sqrt(1 - e * e) * Math.sin(E)];
const [r, u] = [Math.hypot(x, y), Math.atan2(y, x) + perigee];
return [
r * (Math.cos(node) * Math.cos(u) - Math.sin(node) * Math.sin(u) * Math.cos(tilt)),
r * (Math.sin(node) * Math.cos(u) + Math.cos(node) * Math.sin(u) * Math.cos(tilt)),
r * Math.sin(u) * Math.sin(tilt),
];
}
// Ecliptic to equatorial: turn about the equinox by the Earth's tilt.
function equatorial([x, y, z], days) {
const tilt = (23.4393 - 3.563e-7 * days) * rad;
return [x, y * Math.cos(tilt) - z * Math.sin(tilt), y * Math.sin(tilt) + z * Math.cos(tilt)];
}
// Days since 2000 Jan 0, 0h UT: the zero of Paul Schlyter's tables.
const days = time => time / 864e5 + 2440587.5 - 2451543.5;
// Greenwich sidereal time: how far the Earth has turned, in radians.
const sidereal = time => (280.46061837 + 360.98564736629 * (days(time) - 1.5)) * rad;
// Altitude and azimuth in degrees, of a direction in Earth-centred equatorial coordinates.
function look(place, time, [x, y, z]) {
const [lat, turn] = [place.lat * rad, sidereal(time) + place.lon * rad];
const south = Math.sin(lat) * (Math.cos(turn) * x + Math.sin(turn) * y) - Math.cos(lat) * z;
const east = -Math.sin(turn) * x + Math.cos(turn) * y;
const up = Math.cos(lat) * (Math.cos(turn) * x + Math.sin(turn) * y) + Math.sin(lat) * z;
return { alt: Math.atan2(up, Math.hypot(south, east)) / rad, az: (Math.atan2(east, -south) / rad + 360) % 360 };
}
// The observer at sea level, in km from the Earth's centre, on the WGS 84 ellipsoid.
function observer(place, time) {
const [lat, turn, flat] = [place.lat * rad, sidereal(time) + place.lon * rad, 1 / 298.257];
const c = 1 / Math.sqrt(1 - flat * (2 - flat) * Math.sin(lat) ** 2);
const r = 6378.137 * c * Math.cos(lat);
return [r * Math.cos(turn), r * Math.sin(turn), 6378.137 * c * (1 - flat) ** 2 * Math.sin(lat)];
}
// Orbital elements as [value on 2000 Jan 0, change per day]: node, tilt, perihelion, distance in AU, eccentricity, anomaly.
const sun = [[0, 0], [0, 0], [282.9404, 4.70935e-5], [1, 0], [0.016709, -1.151e-9], [356.047, 0.9856002585]];
const planets = {
Mercury: [[48.3313, 3.24587e-5], [7.0047, 5e-8], [29.1241, 1.01444e-5], [0.387098, 0], [0.205635, 5.59e-10], [168.6562, 4.0923344368]],
Venus: [[76.6799, 2.4659e-5], [3.3946, 2.75e-8], [54.891, 1.38374e-5], [0.72333, 0], [0.006773, -1.302e-9], [48.0052, 1.6021302244]],
Mars: [[49.5574, 2.11081e-5], [1.8497, -1.78e-8], [286.5016, 2.92961e-5], [1.523688, 0], [0.093405, 2.516e-9], [18.6021, 0.5240207766]],
Jupiter: [[100.4542, 2.76854e-5], [1.303, -1.557e-7], [273.8777, 1.64505e-5], [5.20256, 0], [0.048498, 4.469e-9], [19.895, 0.0830853001]],
Saturn: [[113.6634, 2.3898e-5], [2.4886, -1.081e-7], [339.3939, 2.97661e-5], [9.55475, 0], [0.055546, -9.499e-9], [316.967, 0.0334442282]],
};
// Seen from the Earth's centre, in AU, equatorial. The Sun's table is the Earth's orbit, seen from the Earth.
function position(elements, time) {
const d = days(time);
const kepler = ([node, tilt, perigee, a, e, anomaly]) => onOrbit(a, e, tilt * rad, node * rad, perigee * rad, anomaly * rad);
const at = table => kepler(table.map(([value, rate]) => value + rate * d));
const [s, p] = [at(sun), elements === sun ? [0, 0, 0] : at(elements)];
return equatorial([s[0] + p[0], s[1] + p[1], s[2] + p[2]], d);
}
// The Moon, from the Astronomical Almanac's low-precision series: about 0.3° off. In Earth radii, plus its parallax.
function moon(time) {
const T = (days(time) - 1.5) / 36525;
const sin = (a, b) => Math.sin((a + b * T) * rad);
const cos = (a, b) => Math.cos((a + b * T) * rad);
const lon = (218.32 + 481267.881 * T + 6.29 * sin(135, 477198.87) - 1.27 * sin(259.3, -413335.36) + 0.66 * sin(235.7, 890534.22)
+ 0.21 * sin(269.9, 954397.74) - 0.19 * sin(357.5, 35999.05) - 0.11 * sin(186.5, 966404.03)) * rad;
const lat = (5.13 * sin(93.3, 483202.02) + 0.28 * sin(228.2, 960400.89) - 0.28 * sin(318.3, 6003.15) - 0.17 * sin(217.6, -407332.21)) * rad;
const parallax = 0.9508 + 0.0518 * cos(135, 477198.87) + 0.0095 * cos(259.3, -413335.36) + 0.0078 * cos(235.7, 890534.22) + 0.0028 * cos(269.9, 954397.74);
const r = 1 / Math.sin(parallax * rad);
return { at: equatorial([r * Math.cos(lat) * Math.cos(lon), r * Math.cos(lat) * Math.sin(lon), r * Math.sin(lat)], days(time)), parallax };
}
// How much of the Moon is lit, 0 to 1, and its age through the month, 0 to 1: 0 new, 0.5 full.
function phase(time) {
const [m, s, pole] = [moon(time).at, position(sun, time), equatorial([0, 0, 1], days(time))];
const cos = (m[0] * s[0] + m[1] * s[1] + m[2] * s[2]) / Math.hypot(...m) / Math.hypot(...s);
const east = pole[0] * (s[1] * m[2] - s[2] * m[1]) + pole[1] * (s[2] * m[0] - s[0] * m[2]) + pole[2] * (s[0] * m[1] - s[1] * m[0]);
return { lit: (1 - cos) / 2, age: east > 0 ? Math.acos(cos) / (2 * Math.PI) : 1 - Math.acos(cos) / (2 * Math.PI) };
}
// The ISS from a TLE: its mean elements, turned by the Earth's flattening (J2) and slowed by drag, as in SGP4's secular terms.
// Short-period wobbles of a few km are left out. Returns a function from time to km, equatorial (SGP4's TEME frame).
function satellite(tle) {
const [, one, two] = tle;
const field = (line, from, to) => Number(line.slice(from, to));
const year = field(one, 18, 20);
const epoch = Date.UTC(year < 57 ? 2000 + year : 1900 + year, 0, 1) + (field(one, 20, 32) - 1) * 864e5;
const drag = (field(one, 33, 43) * 2 * Math.PI) / 1440 ** 2; // half the change in mean motion, rad/min²
const [tilt, node, perigee, anomaly] = [field(two, 8, 16), field(two, 17, 25), field(two, 34, 42), field(two, 43, 51)].map(value => value * rad);
const e = Number('.' + two.slice(26, 33));
const [j2, ke, cos] = [1.082616e-3, 0.0743669161, Math.cos(tilt)];
// The TLE's mean motion is Kozai's; SGP4 turns it into Brouwer's first.
const kozai = (field(two, 52, 63) * 2 * Math.PI) / 1440;
const d1 = (0.75 * j2 * (3 * cos * cos - 1)) / (1 - e * e) ** 1.5;
const a1 = (ke / kozai) ** (2 / 3);
const delta1 = d1 / a1 ** 2;
const a0 = a1 * (1 - delta1 / 3 - delta1 ** 2 - (134 / 81) * delta1 ** 3);
const n = kozai / (1 + d1 / a0 ** 2);
const a = (ke / n) ** (2 / 3);
const k = (1.5 * j2 * n) / (a * (1 - e * e)) ** 2;
const rates = { node: -k * cos, perigee: 0.5 * k * (5 * cos * cos - 1), anomaly: n + 0.5 * k * (3 * cos * cos - 1) * Math.sqrt(1 - e * e) };
const at = time => {
const t = (time - epoch) / minute;
return onOrbit(a * earth, e, tilt, node + rates.node * t, perigee + rates.perigee * t, anomaly + rates.anomaly * t + drag * t * t);
};
at.epoch = epoch;
return at;
}
// Start last: the first draw needs the tables above.
if (saved.lat === undefined) setup.hidden = false;
else tick();
setInterval(tick, 20e3);
</script>