Back

sky.papp

All 422 lines. No libraries, nothing hidden. Read it before you run it.

sky.papp
<!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>
Download sky.papp