0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
The Moon never changes shape — you only ever see more or less of the half the Sun is already lighting — and this puts the proof side by side: a drawn Moon on the left, and on the right the Sun–Earth–Moon diagram showing the one position it could be in to look like that. Everything on the page falls out of a single number, the angle between the Sun and the Moon as seen from here, so the disc, the diagram, the percentage and the dates can never disagree; it is an approximation — a short standard series on the 29.53-day synodic cycle — and it lands the principal phases within a few minutes of the published times, honest enough for planning a walk and not enough for pointing a telescope.
It opens on tonight, so drag the year ribbon first and watch the lit fraction breathe: the disc is drawn at its true apparent size too, swelling from 29.4′ to 33.5′ across as the Moon runs between 406,400 km and 356,700 km away. Then jump to a thin crescent and look at the dark side — that faint ashen disc is real, lit by sunlight bounced off Earth.
Source — the code you see is the code that just ran 1420 lines 67.1 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Moon Phase Explorer — 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); }
:root[data-theme="light"] {
--bg: #F6F6F3; --surface: #FFFFFF; --ink: #1A1D21; --muted: #5A6068;
--line: #E3E2DC; --accent: #C05600; --accent-soft: #FFF3E6;
--run: #1A7F37; --run-soft: #E7F4EA; --danger: #C93C37;
}
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* Demo-local additions. The sky is night in BOTH themes — a black sky painted on
white paper would be a lie — so the moon panel keeps its own dark palette while
every frame, rule and label around it stays on the shared tokens. */
:root {
color-scheme: light;
--shadow: 0 1px 2px rgba(26,29,33,.05);
--sky-hi: #1A2030; /* sky near the horizon */
--sky-lo: #0B0E16; /* sky overhead */
--sky-line: #39415A;
--sky-ink: #EFEDE8;
--sky-label: #A6ADBF;
--earth: #4E7FB0;
--earth-dark: #1E2A38;
--moon-lit: #E9E5DA;
--moon-dark: #232833;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--sky-hi: #141A28; --sky-lo: #06080E; --sky-line: #2B3247;
--sky-ink: #F2F0EB; --sky-label: #949BAC;
--earth: #5A8CBD; --earth-dark: #161E29;
--moon-lit: #EFEBE0; --moon-dark: #1B202A;
}
}
:root[data-theme="light"] {
color-scheme: light;
--shadow: 0 1px 2px rgba(26,29,33,.05);
--sky-hi: #1A2030; --sky-lo: #0B0E16; --sky-line: #39415A;
--sky-ink: #EFEDE8; --sky-label: #A6ADBF;
--earth: #4E7FB0; --earth-dark: #1E2A38;
--moon-lit: #E9E5DA; --moon-dark: #232833;
}
:root[data-theme="dark"] {
color-scheme: dark;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--sky-hi: #141A28; --sky-lo: #06080E; --sky-line: #2B3247;
--sky-ink: #F2F0EB; --sky-label: #949BAC;
--earth: #5A8CBD; --earth-dark: #161E29;
--moon-lit: #EFEBE0; --moon-dark: #1B202A;
}
* { 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: 74ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
main { display: flex; flex-direction: column; gap: 16px; }
.top { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1.06fr); gap: 16px; align-items: start; }
.under { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 16px; align-items: start; }
@media (max-width: 880px) {
.top, .under { grid-template-columns: minmax(0,1fr); }
}
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px 14px; 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;
}
/* ── the moon panel ─────────────────────────────────────────────────────── */
.sky {
position: relative; overflow: hidden; border: 1px solid var(--line);
border-radius: var(--radius); background: var(--sky-lo);
aspect-ratio: 1 / 1; box-shadow: var(--shadow);
}
@media (max-width: 880px) { .sky { aspect-ratio: 5 / 4; } }
.sky canvas { display: block; width: 100%; height: 100%; }
.badge {
position: absolute; left: 12px; bottom: 12px; right: 12px; pointer-events: none;
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px;
}
.badge .name {
color: var(--sky-ink); font-size: clamp(16px, 2.6vw, 21px); font-weight: 600;
letter-spacing: -.015em; line-height: 1.15; text-shadow: 0 1px 10px rgba(0,0,0,.7);
}
.badge .sub {
font-family: var(--font-mono); font-size: 11px; color: var(--sky-label);
letter-spacing: .04em; margin-top: 3px; text-shadow: 0 1px 8px rgba(0,0,0,.8);
}
.badge .pct {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(20px, 3.4vw, 27px); color: var(--sky-ink); line-height: 1;
letter-spacing: -.02em; text-shadow: 0 1px 10px rgba(0,0,0,.7); white-space: nowrap;
}
.badge .pct em { font-style: normal; font-size: 12px; color: var(--sky-label); margin-left: 2px; }
.badge .pctk {
display: block; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--sky-label); margin-top: 4px; text-align: right;
text-shadow: 0 1px 8px rgba(0,0,0,.8);
}
.flipbtn {
position: absolute; right: 10px; top: 10px;
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
color: var(--sky-label); background: rgba(8,10,16,.66);
border: 1px solid var(--sky-line); border-radius: 999px; padding: 5px 10px;
cursor: pointer; line-height: 1.2;
}
.flipbtn:hover { color: var(--sky-ink); border-color: var(--sky-ink); }
.flipbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ── diagram ────────────────────────────────────────────────────────────── */
.geowrap { position: relative; width: 100%; aspect-ratio: 4 / 3; }
.geowrap canvas { display: block; width: 100%; height: 100%; }
.why { margin: 11px 0 0; font-size: 13.5px; line-height: 1.55; color: var(--muted); }
.why b { color: var(--ink); font-weight: 600; }
.note {
margin: 9px 0 0; padding-top: 9px; border-top: 1px solid var(--line);
font-size: 12.5px; line-height: 1.5; color: var(--muted);
}
.note b { color: var(--ink); font-weight: 600; }
/* ── timeline ───────────────────────────────────────────────────────────── */
.tl-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.date {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(15px, 2.5vw, 19px); letter-spacing: -.01em; line-height: 1.2;
}
.date .tod { color: var(--muted); }
.stepper { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.ribbon {
position: relative; width: 100%; height: 92px; margin: 2px 0 4px;
border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--bg);
cursor: crosshair; touch-action: none;
}
.ribbon canvas { display: block; width: 100%; height: 100%; }
@media (max-width: 520px) { .ribbon { height: 76px; } }
.scale { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); margin-top: 3px; }
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 22px; 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: 16px; height: 16px; margin-top: -6px;
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: 14px; height: 14px; 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: 3px; 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.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.mono { font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; }
button.sm { padding: 6px 9px; font-size: 12.5px; }
/* ── stats ──────────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 13px 10px; }
@media (max-width: 420px) { .stats { grid-template-columns: repeat(2, minmax(0,1fr)); } }
.stat { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-size: 18px; line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
/* ── events ─────────────────────────────────────────────────────────────── */
.ev {
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
padding: 9px 10px; margin-bottom: 7px;
}
.ev:last-of-type { margin-bottom: 0; }
.ev .ic { flex: none; width: 22px; height: 22px; }
.ev .txt { min-width: 0; flex: 1 1 auto; }
.ev .t1 { display: block; font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ev .t2 { display: block; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ev .go { flex: none; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .04em; }
.ev:hover:not(:disabled) .go { color: var(--accent); }
.ev:hover:not(:disabled) .t2 { color: var(--accent); }
.evlist { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.evlist .then {
margin: 0 0 6px 8px; font-family: var(--font-mono); font-size: 9.5px;
letter-spacing: .09em; text-transform: uppercase; color: var(--muted);
}
.evlist .ev { padding: 6px 8px; }
.evlist .ev .t1 { font-size: 12px; }
/* on a phone the date needs the room more than the word does */
@media (max-width: 400px) {
.ev .go { font-size: 0; }
.ev .go::after { content: "\2192"; font-size: 13px; }
}
.nocanvas {
margin: 0; padding: 18px 16px; font-size: 13px; line-height: 1.55; color: var(--muted);
}
.sky .nocanvas { color: var(--sky-label); }
.cap { margin: 10px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap .n { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); }
kbd {
font-family: var(--font-mono); font-size: 10px; border: 1px solid var(--line);
border-radius: 4px; padding: 1px 4px; color: var(--ink); background: var(--bg);
}
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>Moon Phase Explorer</h1>
<p class="lede">
The Moon never changes shape — you are just seeing more or less of the half the Sun
is lighting. <b>Drag the date</b> to watch it fill and empty, and the diagram beside it
shows exactly where the Moon has to be for that to happen. It opens on tonight.
</p>
</header>
<main>
<div class="top">
<section class="sky" aria-label="The Moon on the selected date">
<canvas id="disc" aria-hidden="true"></canvas>
<button type="button" class="flipbtn" id="flip"
title="The southern half of the world sees the same Moon the other way up">south view</button>
<div class="badge">
<div>
<div class="name" id="phaseName">Full Moon</div>
<div class="sub" id="phaseSub">waxing</div>
</div>
<div>
<div class="pct" id="pctV">100.0<em>%</em></div>
<span class="pctk">lit as we see it</span>
</div>
</div>
</section>
<section class="card" aria-label="Why the Moon looks like that">
<h2>Where the Moon is on that date</h2>
<div class="geowrap"><canvas id="geo" aria-hidden="true"></canvas></div>
<p class="why" id="why"></p>
<p class="note" id="note"></p>
</section>
</div>
<section class="card" aria-label="Pick a date">
<div class="tl-head">
<div class="date" id="dateBig">—</div>
<div class="stepper">
<button type="button" class="sm mono" id="prevDay" aria-label="Back one day">−1 day</button>
<button type="button" class="sm mono" id="nextDay" aria-label="Forward one day">+1 day</button>
<button type="button" class="sm" id="todayBtn">Tonight</button>
<button type="button" class="sm primary" id="playBtn" aria-pressed="false">Play the year</button>
</div>
</div>
<div class="ribbon" id="ribbon"><canvas id="strip" aria-hidden="true"></canvas></div>
<label class="sr-only" for="dateSlider">Date</label>
<input type="range" id="dateSlider" min="0" max="365" step="1" value="45" aria-valuetext="">
<div class="scale" id="scaleRow" aria-hidden="true"><span>—</span><span>—</span></div>
<p class="cap">
The curve is the lit fraction across a whole year — <b>click anywhere on it</b> to jump,
or use the slider with <kbd>←</kbd> <kbd>→</kbd> for a day at a time.
Filled dots are full moons, hollow ones are new.
</p>
</section>
<div class="under">
<section class="card" aria-label="Numbers for the selected date">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sLit">—</span><span class="k">lit fraction</span></div>
<div class="stat"><span class="v" id="sAge">—</span><span class="k">age of the moon</span></div>
<div class="stat"><span class="v" id="sCycle">—</span><span class="k">cycle, new to new</span></div>
<div class="stat"><span class="v" id="sElong">—</span><span class="k">angle sun–earth–moon</span></div>
<div class="stat"><span class="v" id="sDist">—</span><span class="k">distance from earth</span></div>
<div class="stat"><span class="v" id="sSize">—</span><span class="k">apparent width</span></div>
</div>
<p class="cap">
<b>Approximate.</b> Phases come from a standard short series for the Sun and Moon built on the
<span class="n">29.53</span>-day synodic cycle — good to a few minutes for the phase
times, close enough for planning a walk, not for pointing a telescope. Times are your
device's local clock. The drawn disc even shrinks and grows with the real distance.
</p>
</section>
<section class="card" aria-label="Upcoming phases">
<h2>Coming up</h2>
<button type="button" class="ev" id="evFull">
<svg class="ic" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="9" fill="currentColor"></circle></svg>
<span class="txt"><span class="t1" id="fullDate">—</span><span class="t2" id="fullIn">next full moon</span></span>
<span class="go">jump →</span>
</button>
<button type="button" class="ev" id="evNew">
<svg class="ic" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="8.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-dasharray="2.6 2.4"></circle></svg>
<span class="txt"><span class="t1" id="newDate">—</span><span class="t2" id="newIn">next new moon</span></span>
<span class="go">jump →</span>
</button>
<div class="evlist" id="evList"></div>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<p class="sr-only" id="live" role="status" aria-live="polite"></p>
<script>
(function () {
"use strict";
/* ─────────────────────────────────────────────────────────────────────────
1. Sky mechanics.
A compact standard series: the Sun's longitude to ~0.01 degrees, the Moon's
longitude / latitude / distance from the largest periodic terms. Everything
the page shows is derived from ONE number — the Sun-Earth-Moon angle — so the
disc, the diagram and the dates can never disagree with each other.
───────────────────────────────────────────────────────────────────────── */
var D2R = Math.PI / 180;
var DAY = 86400000;
var SYNODIC = 29.530588853; /* mean new-moon to new-moon, in days */
var RATE = 360 / SYNODIC; /* mean gain in angle, degrees per day */
var MOON_R_KM = 1737.4;
function norm360(x) { x = x % 360; return x < 0 ? x + 360 : x; }
function ang180(x) { x = norm360(x); return x > 180 ? x - 360 : x; }
/* longitude terms: [coefficient, D, M, M', F] */
var LON = [
[6.288774, 0, 0, 1, 0], [1.274027, 2, 0, -1, 0], [0.658314, 2, 0, 0, 0],
[0.213618, 0, 0, 2, 0], [-0.185116, 0, 1, 0, 0], [-0.114332, 0, 0, 0, 2],
[0.058793, 2, 0, -2, 0], [0.057066, 2, -1, -1, 0], [0.053322, 2, 0, 1, 0],
[0.045758, 2, -1, 0, 0], [-0.040923, 0, 1, -1, 0], [-0.034720, 1, 0, 0, 0],
[-0.030383, 0, 1, 1, 0], [0.015327, 2, 0, 0, -2], [-0.012528, 0, 0, 1, 2],
[0.010980, 0, 0, 1, -2], [0.010675, 4, 0, -1, 0], [0.010034, 0, 0, 3, 0],
[0.008548, 4, 0, -2, 0], [-0.007888, 2, 1, -1, 0], [-0.006766, 2, 1, 0, 0],
[-0.005163, 1, 0, -1, 0], [0.004987, 1, 1, 0, 0], [0.004036, 2, -1, 1, 0],
[0.003994, 2, 0, 2, 0], [0.003861, 4, 0, 0, 0], [0.003665, 2, 0, -3, 0]
];
var LAT = [
[5.128122, 0, 0, 0, 1], [0.280602, 0, 0, 1, 1], [0.277693, 0, 0, 1, -1],
[0.173237, 2, 0, 0, -1], [0.055413, 2, 0, -1, 1], [0.046271, 2, 0, -1, -1],
[0.032573, 2, 0, 0, 1], [0.017198, 0, 0, 2, 1], [0.009266, 2, 0, 1, -1],
[0.008822, 0, 0, 2, -1], [0.008216, 2, -1, 0, -1], [0.004324, 2, 0, -2, -1],
[0.004200, 2, 0, 1, 1]
];
var DIST = [
[-20905355, 0, 0, 1, 0], [-3699111, 2, 0, -1, 0], [-2955968, 2, 0, 0, 0],
[-569925, 0, 0, 2, 0], [48888, 0, 1, 0, 0], [-3149, 0, 0, 0, 2],
[246158, 2, 0, -2, 0], [-152138, 2, -1, -1, 0], [-170733, 2, 0, 1, 0],
[-204586, 2, -1, 0, 0], [-129620, 0, 1, -1, 0], [108743, 1, 0, 0, 0],
[104755, 0, 1, 1, 0], [10321, 2, 0, 0, -2], [79661, 0, 0, 1, -2],
[-34782, 4, 0, -1, 0], [-23210, 0, 0, 3, 0], [-21636, 4, 0, -2, 0],
[24208, 2, 1, -1, 0], [30824, 2, 1, 0, 0], [-8379, 1, 0, -1, 0],
[-16675, 1, 1, 0, 0], [-12831, 2, -1, 1, 0], [-10445, 2, 0, 2, 0],
[-11650, 4, 0, 0, 0], [14403, 2, 0, -3, 0]
];
function series(tab, D, M, Mp, F, useCos) {
var s = 0;
for (var i = 0; i < tab.length; i++) {
var r = tab[i];
var a = r[1] * D + r[2] * M + r[3] * Mp + r[4] * F;
s += r[0] * (useCos ? Math.cos(a) : Math.sin(a));
}
return s;
}
/* Everything about one instant. ms = milliseconds since the Unix epoch. */
function state(ms) {
var jd = ms / DAY + 2440587.5;
var T = (jd - 2451545.0) / 36525;
/* Sun */
var L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
var Ms = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
var msr = Ms * D2R;
var C = 1.914602 * Math.sin(msr) + 0.019993 * Math.sin(2 * msr) + 0.000289 * Math.sin(3 * msr);
var sunLon = norm360(L0 + C);
var ecc = 0.016708634 - 0.000042037 * T;
var nu = msr + C * D2R;
var sunKm = (1.000001018 * (1 - ecc * ecc) / (1 + ecc * Math.cos(nu))) * 149597870.7;
/* Moon */
var Lp = 218.3164477 + 481267.88123421 * T - 0.0015786 * T * T;
var D = (297.8501921 + 445267.1114034 * T - 0.0018819 * T * T) * D2R;
var M = (357.5291092 + 35999.0502909 * T - 0.0001536 * T * T) * D2R;
var Mp = (134.9633964 + 477198.8675055 * T + 0.0087414 * T * T) * D2R;
var F = (93.2720950 + 483202.0175233 * T - 0.0036539 * T * T) * D2R;
var moonLon = norm360(Lp + series(LON, D, M, Mp, F, false));
var moonLat = series(LAT, D, M, Mp, F, false);
var moonKm = 385000.56 + series(DIST, D, M, Mp, F, true) / 1000;
/* elongation: how far round the cycle from new moon, 0..360 */
var elong = norm360(moonLon - sunLon);
var cosPsi = Math.cos(moonLat * D2R) * Math.cos((moonLon - sunLon) * D2R);
var psi = Math.acos(Math.max(-1, Math.min(1, cosPsi)));
/* phase angle at the Moon; the Sun is far but not infinitely far */
var pa = Math.atan2(sunKm * Math.sin(psi), moonKm - sunKm * Math.cos(psi));
var k = (1 + Math.cos(pa)) / 2;
return {
ms: ms, elong: elong, k: k, km: moonKm, sep: psi / D2R,
waxing: elong < 180,
/* apparent radius of the disc, in degrees */
semi: Math.asin(MOON_R_KM / moonKm) / D2R
};
}
/* Solve for the instant when the elongation equals `target` degrees.
The elongation always climbs (11.7-13.0 deg/day), so a fixed-slope Newton
step converges in a handful of passes. */
function solve(target, guess) {
var t = guess;
for (var n = 0; n < 7; n++) {
t -= (ang180(state(t).elong - target) / RATE) * DAY;
}
return t;
}
function nextPhase(target, from) {
var ahead = norm360(target - state(from).elong) / RATE;
var t = solve(target, from + ahead * DAY);
if (t <= from + 60000) t = solve(target, t + SYNODIC * DAY);
return t;
}
/* Aim at the NEAREST event on either side (a signed offset), then step back one
cycle only if that event is genuinely ahead of us. Aiming with an unsigned
0..360 offset skipped a whole cycle whenever `from` sat a hair *before* the
target — exactly where the jump buttons land — which made the age of the moon
read 29.4 days at the very moment of new moon. */
function prevPhase(target, from) {
var t = solve(target, from - (ang180(state(from).elong - target) / RATE) * DAY);
if (t > from + 60000) t = solve(target, t - SYNODIC * DAY);
return t;
}
/* Phase naming: tight windows for the four principal phases, the crescent and
gibbous names for the stretches in between. */
var PHASES = [
{ n: "New Moon", s: "the lit half faces away" },
{ n: "Waxing Crescent", s: "filling · lit edge leads" },
{ n: "First Quarter", s: "half lit · filling" },
{ n: "Waxing Gibbous", s: "filling · nearly full" },
{ n: "Full Moon", s: "we face the whole lit side" },
{ n: "Waning Gibbous", s: "emptying · past full" },
{ n: "Last Quarter", s: "half lit · emptying" },
{ n: "Waning Crescent", s: "emptying · back toward new" }
];
function phaseIndex(elong) {
var f = elong / 360;
if (f < 0.02 || f >= 0.98) return 0;
if (f < 0.23) return 1;
if (f < 0.27) return 2;
if (f < 0.48) return 3;
if (f < 0.52) return 4;
if (f < 0.73) return 5;
if (f < 0.77) return 6;
return 7;
}
var WHY = [
"The Moon sits almost between us and the Sun. Its lit half is pointed away from Earth, so we get the night side — and it rises and sets with the Sun, lost in the glare.",
"The Moon has swung a little to one side of the Sun, so a sliver of the lit half swings into view with it. Look low in the west just after sunset.",
"A quarter of the way round the orbit the Moon makes a right angle with the Sun, so we see exactly half of the lit face. It is already high in the sky when the Sun goes down.",
"More than a quarter round now: we see most of the lit half at once, bright enough to throw a shadow on a clear night.",
"The Moon is nearly opposite the Sun, so the whole lit face points our way. It climbs as the Sun sets and stays up the entire night.",
"Past the far side of the orbit. The lit half is turning away from us again, and the Moon comes up later and later each night.",
"Three quarters round: another right angle, so another half-lit face — but the opposite half from before. This one hangs around in the morning sky.",
"Closing the loop back toward the Sun. Only a thin arc is left, and it comes up shortly before dawn."
];
/* ─────────────────────────────────────────────────────────────────────────
2. Formatting. Machine facts stay in mono, and stay deterministic.
───────────────────────────────────────────────────────────────────────── */
var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function pad(n) { return (n < 10 ? "0" : "") + n; }
function fmtDate(ms) {
var d = new Date(ms);
return DAYS[d.getDay()] + " " + d.getDate() + " " + MONTHS[d.getMonth()] + " " + d.getFullYear();
}
function fmtTime(ms) {
var d = new Date(ms);
return pad(d.getHours()) + ":" + pad(d.getMinutes());
}
function fmtGap(ms) {
var mins = Math.round(ms / 60000);
if (mins < 1) return "now";
if (mins < 60) return "in " + mins + " min";
var h = Math.floor(mins / 60);
if (h < 48) return "in " + h + " h";
var d = Math.floor(h / 24);
return "in " + d + " d " + (h - d * 24) + " h";
}
function group(n) {
var s = String(Math.round(n)), out = "", c = 0;
for (var i = s.length - 1; i >= 0; i--) {
out = s.charAt(i) + out;
if (++c % 3 === 0 && i > 0) out = "," + out;
}
return out;
}
/* ─────────────────────────────────────────────────────────────────────────
3. Canvas helpers + theme colours.
───────────────────────────────────────────────────────────────────────── */
var css = {};
function readTheme() {
var s = getComputedStyle(document.documentElement);
["--bg", "--surface", "--ink", "--muted", "--line", "--accent", "--accent-soft",
"--sky-hi", "--sky-lo", "--sky-line", "--sky-ink", "--sky-label",
"--earth", "--earth-dark", "--moon-lit", "--moon-dark"].forEach(function (k) {
css[k] = s.getPropertyValue(k).trim() || "#888";
});
}
function rgba(hex, a) {
var h = hex.replace("#", "");
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 (isNaN(n)) return "rgba(128,128,128," + a + ")";
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
/* true unless the browser refuses a 2D context — every draw call checks it */
var canDraw = (function () {
try {
var probe = document.createElement("canvas");
return !!(probe.getContext && probe.getContext("2d"));
} catch (e) { return false; }
})();
function fit(cv) {
var r = cv.getBoundingClientRect();
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
var ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, r.width, r.height);
return { ctx: ctx, w: r.width || 1, h: r.height || 1 };
}
/* small deterministic generator so the Moon looks the same every load */
function rng(seed) {
var s = seed >>> 0;
return function () {
s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0;
return s / 4294967296;
};
}
/* ─────────────────────────────────────────────────────────────────────────
4. The Moon's face, painted once into an offscreen canvas.
Dark seas roughly where the real ones are, then craters, rays and the
darkening toward the limb. Drawn north-up, east to the right.
───────────────────────────────────────────────────────────────────────── */
var TEX = null, TEX_SIZE = 620;
var SEAS = [
[-0.30, -0.36, 0.27], [-0.52, -0.06, 0.30], [0.03, -0.30, 0.17],
[0.24, -0.09, 0.19], [0.55, -0.24, 0.10], [0.45, 0.12, 0.14],
[0.30, 0.24, 0.09], [-0.20, 0.31, 0.16], [-0.44, 0.29, 0.12],
[-0.06, -0.13, 0.09], [-0.16, -0.63, 0.09], [-0.62, -0.40, 0.14]
];
function buildTexture() {
if (!canDraw) return null;
var c = document.createElement("canvas");
c.width = c.height = TEX_SIZE;
var x = c.getContext("2d");
var R = TEX_SIZE / 2, cx = R, cy = R;
var rand = rng(20260813);
x.save();
x.beginPath(); x.arc(cx, cy, R, 0, Math.PI * 2); x.clip();
/* highland base with a touch of large-scale mottle */
x.fillStyle = "#dad5c9";
x.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
for (var m = 0; m < 60; m++) {
var mx = rand() * TEX_SIZE, my = rand() * TEX_SIZE, mr = (0.06 + rand() * 0.18) * R;
var g0 = x.createRadialGradient(mx, my, 0, mx, my, mr);
var tone = rand() < 0.5 ? "255,255,255" : "150,145,135";
g0.addColorStop(0, "rgba(" + tone + ",0.13)");
g0.addColorStop(1, "rgba(" + tone + ",0)");
x.fillStyle = g0; x.beginPath(); x.arc(mx, my, mr, 0, Math.PI * 2); x.fill();
}
/* the seas: a few overlapping soft blobs each */
for (var s = 0; s < SEAS.length; s++) {
var sea = SEAS[s];
for (var b = 0; b < 5; b++) {
var bx = cx + (sea[0] + (rand() - 0.5) * sea[2] * 0.7) * R;
var by = cy + (sea[1] + (rand() - 0.5) * sea[2] * 0.7) * R;
var br = sea[2] * R * (0.55 + rand() * 0.55);
var g = x.createRadialGradient(bx, by, br * 0.15, bx, by, br);
g.addColorStop(0, "rgba(104,102,100,0.62)");
g.addColorStop(0.72, "rgba(112,110,108,0.42)");
g.addColorStop(1, "rgba(120,118,116,0)");
x.fillStyle = g;
x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
}
}
/* rays out of the bright southern crater */
var tx = cx - 0.11 * R, ty = cy + 0.50 * R;
for (var r0 = 0; r0 < 22; r0++) {
var a = rand() * Math.PI * 2, len = (0.35 + rand() * 0.75) * R;
var wdt = 0.008 * R + rand() * 0.02 * R;
var gr = x.createLinearGradient(tx, ty, tx + Math.cos(a) * len, ty + Math.sin(a) * len);
gr.addColorStop(0, "rgba(255,255,255,0.22)");
gr.addColorStop(1, "rgba(255,255,255,0)");
x.strokeStyle = gr; x.lineWidth = wdt; x.lineCap = "round";
x.beginPath(); x.moveTo(tx, ty); x.lineTo(tx + Math.cos(a) * len, ty + Math.sin(a) * len); x.stroke();
}
/* craters */
for (var i = 0; i < 150; i++) {
var ang = rand() * Math.PI * 2, rad = Math.sqrt(rand()) * R * 0.97;
var px = cx + Math.cos(ang) * rad, py = cy + Math.sin(ang) * rad;
var pr = (0.006 + Math.pow(rand(), 2.6) * 0.055) * R;
x.fillStyle = "rgba(96,92,86,0.30)";
x.beginPath(); x.arc(px, py, pr, 0, Math.PI * 2); x.fill();
x.strokeStyle = "rgba(255,255,255,0.30)";
x.lineWidth = Math.max(0.6, pr * 0.22);
x.beginPath(); x.arc(px - pr * 0.06, py - pr * 0.06, pr * 0.94, 0, Math.PI * 2); x.stroke();
}
/* the two brightest young craters */
[[tx, ty, 0.055], [cx + 0.32 * R, cy + 0.46 * R, 0.036]].forEach(function (p) {
var g2 = x.createRadialGradient(p[0], p[1], 0, p[0], p[1], p[2] * R * 2.2);
g2.addColorStop(0, "rgba(255,255,255,0.55)");
g2.addColorStop(1, "rgba(255,255,255,0)");
x.fillStyle = g2;
x.beginPath(); x.arc(p[0], p[1], p[2] * R * 2.2, 0, Math.PI * 2); x.fill();
});
/* limb darkening */
var ld = x.createRadialGradient(cx, cy, R * 0.35, cx, cy, R);
ld.addColorStop(0, "rgba(0,0,0,0)");
ld.addColorStop(0.78, "rgba(0,0,0,0.10)");
ld.addColorStop(1, "rgba(0,0,0,0.46)");
x.fillStyle = ld; x.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
x.restore();
return c;
}
/* the lit region: a half circle on the sunward side plus or minus a half
ellipse whose width is the cosine of the phase angle */
function litPath(ctx, cx, cy, R, cosA) {
var c = Math.max(-1, Math.min(1, cosA));
ctx.beginPath();
ctx.arc(cx, cy, R, -Math.PI / 2, Math.PI / 2, false);
ctx.ellipse(cx, cy, R * Math.abs(c), R, 0, Math.PI / 2, -Math.PI / 2, c > 0);
ctx.closePath();
}
/* ─────────────────────────────────────────────────────────────────────────
5. State + elements
───────────────────────────────────────────────────────────────────────── */
var $ = function (id) { return document.getElementById(id); };
var discCv = $("disc"), geoCv = $("geo"), stripCv = $("strip");
var slider = $("dateSlider"), ribbon = $("ribbon");
var mqMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
var reduce = mqMotion.matches;
var now = Date.now();
var midnight = new Date(); midnight.setHours(0, 0, 0, 0);
var todMs = now - midnight.getTime(); /* keep the time of day */
var winStart = midnight.getTime() - 45 * DAY; /* a rolling year of dates */
var winDays = 365;
var winEnd = winStart + winDays * DAY;
/* the last slider notch still carries the time of day, so the far end of the
track is a real evening rather than snapping back to midnight */
var tMax = winEnd + todMs;
var tMs = now;
var flipped = false;
var playing = false;
var st = state(tMs);
var stripData = null; /* precomputed curve + markers for the ribbon */
/* ─────────────────────────────────────────────────────────────────────────
6. Drawing: the Moon
───────────────────────────────────────────────────────────────────────── */
function drawDisc() {
if (!canDraw) return;
var f = fit(discCv), ctx = f.ctx, W = f.w, H = f.h;
var cx = W / 2, cy = H / 2 - Math.min(18, H * 0.045);
var base = Math.min(W, H) * 0.325;
/* the disc really does change size: 29.4' to 33.5' across a month */
var R = base * (st.semi / 0.259);
/* night sky */
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, css["--sky-lo"]);
sky.addColorStop(1, css["--sky-hi"]);
ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
var rand = rng(99173);
for (var i = 0; i < 120; i++) {
var sx = rand() * W, sy = rand() * H, sr = 0.3 + rand() * 1.1;
var d = Math.hypot(sx - cx, sy - cy);
var a = (0.15 + rand() * 0.6) * Math.min(1, Math.max(0.12, (d - R) / (R * 1.8)));
ctx.fillStyle = "rgba(255,255,255," + a.toFixed(3) + ")";
ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill();
}
/* moonglow, stronger the more of it is lit */
var glow = ctx.createRadialGradient(cx, cy, R * 0.9, cx, cy, R * 2.7);
glow.addColorStop(0, "rgba(226,232,246," + (0.16 * st.k + 0.02).toFixed(3) + ")");
glow.addColorStop(1, "rgba(226,232,246,0)");
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, R * 2.7, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.translate(cx, cy);
/* the southern half of the world sees the same face turned upside down */
if (flipped) ctx.rotate(Math.PI);
/* Earthshine: the unlit part is faintly lit by light bounced off Earth. It is
thousands of times fainter than the sunlit crescent and it only shows near
new moon, so BOTH layers here have to fade with the phase. Filling the
unlit side with a flat opaque slab drew a grey ball at every phase — a
4%-lit crescent and a half moon both came out as a full disc, flatly
contradicting the number printed beside them. The unlit side is mostly
*sky*; let the sky through and lift it only as the Moon closes on new. */
ctx.save();
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.clip();
ctx.globalAlpha = 0.12 + 0.30 * Math.pow(1 - st.k, 2);
ctx.fillStyle = css["--moon-dark"];
ctx.fillRect(-R, -R, R * 2, R * 2);
ctx.globalAlpha = 0.008 + 0.045 * Math.pow(1 - st.k, 3);
ctx.drawImage(TEX, -R, -R, R * 2, R * 2);
ctx.restore();
/* The sunlit part, in three passes so the terminator is not a razor edge.
cos of the phase angle: +1 at new, 0 at the quarters, -1 at full.
Waxing moons are lit on the right hand side from the northern half of the
world; the lit shape is symmetric top to bottom, so a half turn of the
path (and only the path) puts the light on the other side. */
var cosA = 1 - 2 * st.k;
var half = st.waxing ? 0 : Math.PI;
/* Soften the terminator by the width of the crescent, not by a fixed amount:
a flat 0.05 is a pleasant haze at the quarters but smears a thin crescent
across a tenth of the disc and leaves a visible double edge. */
var soft = 0.06 * (1 - Math.abs(cosA));
var passes = soft > 0.002 ? [[cosA - soft, 0.34], [cosA, 0.55], [cosA + soft, 1]] : [[cosA, 1]];
for (var p = 0; p < passes.length; p++) {
ctx.save();
ctx.rotate(half);
litPath(ctx, 0, 0, R, passes[p][0]);
ctx.clip();
ctx.rotate(-half); /* the face itself stays the right way up */
ctx.globalAlpha = passes[p][1];
ctx.drawImage(TEX, -R, -R, R * 2, R * 2);
ctx.restore();
}
ctx.restore();
/* rim */
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
/* where the sunlight is coming from — the same arrow direction as the diagram */
var right = flipped ? !st.waxing : st.waxing;
var dir = right ? -1 : 1; /* travel direction of the light */
var ax = cx + (right ? 1 : -1) * (R + Math.min(46, W * 0.12));
/* keep the arrows and their label inside the frame on a narrow phone — the
clamp still lands clear of the disc, so they never sit on top of the Moon */
ax = Math.max(26, Math.min(W - 26, ax));
var ay = cy - R * 0.55;
ctx.save();
ctx.strokeStyle = rgba(css["--accent"], 0.75);
ctx.fillStyle = rgba(css["--accent"], 0.75);
ctx.lineWidth = 1.4;
for (var k2 = 0; k2 < 3; k2++) {
var y = ay + k2 * 13;
var x0 = ax - dir * 16, x1 = ax + dir * 16;
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x1, y); ctx.lineTo(x1 - dir * 5, y - 3.2); ctx.lineTo(x1 - dir * 5, y + 3.2);
ctx.closePath(); ctx.fill();
}
ctx.font = '10px ' + css_mono();
ctx.fillStyle = rgba(css["--accent"], 0.85);
ctx.textAlign = "center"; ctx.textBaseline = "bottom";
ctx.fillText("sunlight", ax, ay - 8);
ctx.restore();
}
function css_mono() {
return 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace';
}
/* ─────────────────────────────────────────────────────────────────────────
7. Drawing: the sun / earth / moon diagram
───────────────────────────────────────────────────────────────────────── */
function drawGeo() {
if (!canDraw) return;
var f = fit(geoCv), ctx = f.ctx, W = f.w, H = f.h;
var small = W < 330;
var cx = W * 0.60, cy = H * 0.52;
var orbit = Math.min(W * 0.30, H * 0.38);
var er = Math.max(9, orbit * 0.20);
var mr = Math.max(6, orbit * 0.135);
ctx.fillStyle = css["--surface"]; ctx.fillRect(0, 0, W, H);
/* Every word painted on this canvas can end up over the sun's wash, so all of
them go down with a surface-coloured halo first. Without it the labels near
the Sun were amber-on-amber. */
function tag(text, x, y, col) {
ctx.lineJoin = "round"; ctx.lineWidth = 3.5;
ctx.strokeStyle = css["--surface"];
ctx.strokeText(text, x, y);
ctx.fillStyle = col;
ctx.fillText(text, x, y);
}
/* the Sun, off to the left, with sunlight crossing the frame */
var sx = -W * 0.10, sy = cy, sr = Math.min(W * 0.30, H * 0.42);
/* Stop the glow short of the orbit. At new moon the Moon sits on the sunward
side of the orbit, and a wash that reached that far buried it — and its
label — in solid amber. */
var glowR = Math.min(sr * 1.5, Math.max(sr * 0.75, (cx - orbit) - sx - mr - 16));
var sg = ctx.createRadialGradient(sx, sy, sr * 0.2, sx, sy, glowR);
sg.addColorStop(0, rgba(css["--accent"], 0.55));
sg.addColorStop(0.55, rgba(css["--accent"], 0.26));
sg.addColorStop(1, rgba(css["--accent"], 0));
ctx.fillStyle = sg;
ctx.beginPath(); ctx.arc(sx, sy, glowR, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = css["--accent"];
ctx.beginPath(); ctx.arc(sx, sy, sr * 0.62, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = rgba(css["--accent"], 0.30);
ctx.fillStyle = rgba(css["--accent"], 0.30);
ctx.lineWidth = 1.2;
var rayTop = cy - orbit * 1.28, rayBot = cy + orbit * 1.28;
for (var i = 0; i < 5; i++) {
var y = rayTop + (rayBot - rayTop) * (i / 4);
var x0 = sx + sr * 0.72, x1 = cx + orbit * 1.24;
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x1, y); ctx.lineTo(x1 - 6, y - 3.6); ctx.lineTo(x1 - 6, y + 3.6);
ctx.closePath(); ctx.fill();
}
ctx.font = "10.5px " + css_mono();
ctx.textAlign = "left"; ctx.textBaseline = "top";
tag("SUN", 8, 8, css["--accent"]);
ctx.textAlign = "right";
tag("not to scale", W - 8, 8, css["--muted"]);
/* the orbit */
ctx.strokeStyle = css["--line"];
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
/* how far round the cycle we are: an arc from new moon to here */
var aOf = function (e) { return (180 - e) * D2R; };
ctx.strokeStyle = rgba(css["--accent"], 0.85);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(cx, cy, orbit, aOf(0), aOf(st.elong), true);
ctx.stroke();
/* the four principal positions */
var marks = [[0, "new"], [90, "first qr"], [180, "full"], [270, "last qr"]];
ctx.font = "9.5px " + css_mono();
for (var m = 0; m < marks.length; m++) {
var a = aOf(marks[m][0]);
var px = cx + Math.cos(a) * orbit, py = cy + Math.sin(a) * orbit;
ctx.fillStyle = css["--line"];
ctx.beginPath(); ctx.arc(px, py, 2.6, 0, Math.PI * 2); ctx.fill();
/* When the Moon is parked on one of these marks its own "n% lit" label lands
on top of the mark's name. Drop the name — the Moon is standing on it. */
if (!small && Math.abs(ang180(marks[m][0] - st.elong)) > 22) {
var lx = cx + Math.cos(a) * (orbit + 20), ly = cy + Math.sin(a) * (orbit + 20);
ctx.textAlign = Math.abs(Math.cos(a)) < 0.3 ? "center" : (Math.cos(a) > 0 ? "left" : "right");
ctx.textBaseline = Math.abs(Math.cos(a)) < 0.3 ? (Math.sin(a) > 0 ? "top" : "bottom") : "middle";
tag(marks[m][1], lx, ly, css["--muted"]);
}
}
/* direction of travel */
var ta = aOf(st.elong + 26);
var tx = cx + Math.cos(ta) * orbit, ty = cy + Math.sin(ta) * orbit;
var tang = ta - Math.PI / 2;
ctx.save();
ctx.translate(tx, ty); ctx.rotate(tang + Math.PI);
ctx.fillStyle = css["--muted"];
ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-3, 4); ctx.lineTo(-3, -4); ctx.closePath(); ctx.fill();
ctx.restore();
/* Earth: lit on the side facing the Sun */
ctx.fillStyle = css["--earth-dark"];
ctx.beginPath(); ctx.arc(cx, cy, er, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, er, Math.PI / 2, -Math.PI / 2, false); ctx.closePath(); ctx.clip();
ctx.fillStyle = css["--earth"];
ctx.beginPath(); ctx.arc(cx, cy, er, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.strokeStyle = rgba(css["--ink"], 0.4); ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, er, 0, Math.PI * 2); ctx.stroke();
ctx.font = "9.5px " + css_mono();
ctx.textAlign = "center"; ctx.textBaseline = "top";
tag("EARTH", cx, cy + er + 5, css["--muted"]);
/* the Moon at its place in the orbit */
var ma = aOf(st.elong);
var mx = cx + Math.cos(ma) * orbit, my = cy + Math.sin(ma) * orbit;
/* line of sight from Earth */
ctx.strokeStyle = rgba(css["--ink"], 0.42);
ctx.setLineDash([2, 3]); ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(mx, my); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = css["--moon-dark"];
ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.beginPath(); ctx.arc(mx, my, mr, Math.PI / 2, -Math.PI / 2, false); ctx.closePath(); ctx.clip();
ctx.fillStyle = css["--moon-lit"];
ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill();
ctx.restore();
/* the lit half is near-white cream, which on a white card is invisible on its
own — the rim and the terminator line are what make the split readable */
ctx.strokeStyle = rgba(css["--ink"], 0.55); ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = rgba(css["--ink"], 0.35);
ctx.beginPath(); ctx.moveTo(mx, my - mr); ctx.lineTo(mx, my + mr); ctx.stroke();
/* a ring so the Moon reads as the live object here */
ctx.strokeStyle = css["--accent"]; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(mx, my, mr + 5, 0, Math.PI * 2); ctx.stroke();
/* Label it with what we see from Earth. It hangs off the Moon on the side
facing away from Earth, which at full moon put it past the right-hand edge
of the canvas — "100% lit" came out clipped to "100%". Flip it to the other
side of the Moon rather than let it run off the frame. */
var pad = 6;
var txt = Math.round(st.k * 100) + "% lit";
ctx.font = "10.5px " + css_mono();
var tw = ctx.measureText(txt).width;
var lx2 = mx + Math.cos(ma) * (mr + 14), ly2 = my + Math.sin(ma) * (mr + 14);
var al = Math.cos(ma) > 0.2 ? "left" : (Math.cos(ma) < -0.2 ? "right" : "center");
if (al === "left" && lx2 + tw > W - pad) { al = "right"; lx2 = mx - (mr + 14); }
else if (al === "right" && lx2 - tw < pad) { al = "left"; lx2 = mx + (mr + 14); }
else if (al === "center") lx2 = Math.max(pad + tw / 2, Math.min(W - pad - tw / 2, lx2));
var bl = Math.sin(ma) > 0.2 ? "top" : (Math.sin(ma) < -0.2 ? "bottom" : "middle");
if (bl === "top" && ly2 + 12 > H - pad) { bl = "bottom"; ly2 = my - (mr + 14); }
else if (bl === "bottom" && ly2 - 12 < pad) { bl = "top"; ly2 = my + (mr + 14); }
ctx.textAlign = al; ctx.textBaseline = bl;
tag(txt, lx2, ly2, css["--ink"]);
/* footnote inside the frame */
ctx.font = "9.5px " + css_mono();
ctx.textAlign = "left"; ctx.textBaseline = "bottom";
tag("dashed line = what we look along", 8, H - 8, css["--muted"]);
}
/* ─────────────────────────────────────────────────────────────────────────
8. Drawing: the year ribbon
───────────────────────────────────────────────────────────────────────── */
function buildStrip(W) {
var pts = [], step = winDays / Math.max(2, Math.floor(W));
for (var d = 0; d <= winDays; d += step) {
pts.push([d, state(winStart + d * DAY).k]);
}
/* full and new moons inside the window */
var fulls = [], news = [], t = winStart;
while (true) {
t = nextPhase(180, t);
if (t > winEnd) break;
fulls.push(t);
}
t = winStart;
while (true) {
t = nextPhase(0, t);
if (t > winEnd) break;
news.push(t);
}
/* month boundaries */
var months = [], d0 = new Date(winStart);
var mm = new Date(d0.getFullYear(), d0.getMonth(), 1);
while (mm.getTime() < winEnd) {
if (mm.getTime() > winStart) months.push(mm.getTime());
mm = new Date(mm.getFullYear(), mm.getMonth() + 1, 1);
}
return { pts: pts, fulls: fulls, news: news, months: months, w: W };
}
function drawStrip() {
if (!canDraw) return;
var f = fit(stripCv), ctx = f.ctx, W = f.w, H = f.h;
if (!stripData || Math.abs(stripData.w - W) > 24) stripData = buildStrip(W);
var pad = 14, top = 16, bot = H - 12;
var X = function (d) { return (d / winDays) * W; };
var Y = function (k) { return bot - k * (bot - top); };
ctx.fillStyle = css["--bg"]; ctx.fillRect(0, 0, W, H);
/* months */
ctx.font = "9.5px " + css_mono();
ctx.textBaseline = "top";
for (var i = 0; i < stripData.months.length; i++) {
var x = X((stripData.months[i] - winStart) / DAY);
ctx.strokeStyle = css["--line"]; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(Math.round(x) + 0.5, 0); ctx.lineTo(Math.round(x) + 0.5, H); ctx.stroke();
if (W > 380 || i % 2 === 0) {
ctx.fillStyle = css["--muted"]; ctx.textAlign = "left";
ctx.fillText(MONTHS[new Date(stripData.months[i]).getMonth()], x + 3, 3);
}
}
/* the lit-fraction curve */
var pts = stripData.pts;
ctx.beginPath();
ctx.moveTo(X(pts[0][0]), bot);
for (var p = 0; p < pts.length; p++) ctx.lineTo(X(pts[p][0]), Y(pts[p][1]));
ctx.lineTo(X(pts[pts.length - 1][0]), bot);
ctx.closePath();
var g = ctx.createLinearGradient(0, top, 0, bot);
g.addColorStop(0, rgba(css["--accent"], 0.28));
g.addColorStop(1, rgba(css["--accent"], 0.03));
ctx.fillStyle = g; ctx.fill();
ctx.beginPath();
for (var q = 0; q < pts.length; q++) {
var px = X(pts[q][0]), py = Y(pts[q][1]);
if (q === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.strokeStyle = css["--accent"]; ctx.lineWidth = 1.4; ctx.stroke();
/* markers */
for (var a = 0; a < stripData.fulls.length; a++) {
var fx = X((stripData.fulls[a] - winStart) / DAY);
ctx.fillStyle = css["--ink"];
ctx.beginPath(); ctx.arc(fx, Y(1), 3.2, 0, Math.PI * 2); ctx.fill();
}
for (var b = 0; b < stripData.news.length; b++) {
var nx = X((stripData.news[b] - winStart) / DAY);
ctx.strokeStyle = css["--muted"]; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(nx, Y(0), 2.8, 0, Math.PI * 2); ctx.stroke();
}
/* today */
var tx = X((now - winStart) / DAY);
ctx.strokeStyle = rgba(css["--muted"], 0.75);
ctx.setLineDash([2, 3]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(tx, 0); ctx.lineTo(tx, H); ctx.stroke();
ctx.setLineDash([]);
/* the cursor */
var cxp = X((tMs - winStart) / DAY);
ctx.strokeStyle = css["--ink"]; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(cxp, 0); ctx.lineTo(cxp, H); ctx.stroke();
var cy2 = Y(st.k);
ctx.fillStyle = css["--accent"];
ctx.beginPath(); ctx.arc(cxp, cy2, 4.2, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = css["--surface"]; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(cxp, cy2, 4.2, 0, Math.PI * 2); ctx.stroke();
/* axis hints */
ctx.font = "9px " + css_mono();
ctx.fillStyle = css["--muted"];
ctx.textAlign = "right"; ctx.textBaseline = "top";
ctx.fillText("full", W - 5, Y(1) - 2);
ctx.textBaseline = "bottom";
ctx.fillText("new", W - 5, Y(0) + 3);
}
/* ─────────────────────────────────────────────────────────────────────────
9. Text panels
───────────────────────────────────────────────────────────────────────── */
var lastSpoken = "";
function render(full) {
st = state(tMs);
var idx = phaseIndex(st.elong);
drawDisc();
drawGeo();
drawStrip();
$("phaseName").innerHTML = PHASES[idx].n;
$("phaseSub").innerHTML = PHASES[idx].s;
$("pctV").innerHTML = (st.k * 100).toFixed(1) + "<em>%</em>";
$("why").innerHTML = WHY[idx];
$("dateBig").innerHTML = fmtDate(tMs) + ' <span class="tod">' + fmtTime(tMs) + "</span>";
slider.setAttribute("aria-valuetext", fmtDate(tMs) + ", " + PHASES[idx].n +
", " + Math.round(st.k * 100) + " percent lit");
/* numbers */
var lastNew = prevPhase(0, tMs), nextNew = nextPhase(0, tMs);
var age = Math.max(0, (tMs - lastNew) / DAY);
var cycle = (nextNew - lastNew) / DAY;
$("sLit").innerHTML = (st.k * 100).toFixed(1) + '<span class="u">%</span>';
$("sAge").innerHTML = age.toFixed(1) + '<span class="u">d</span>';
$("sCycle").innerHTML = cycle.toFixed(2) + '<span class="u">d</span>';
$("sElong").innerHTML = st.sep.toFixed(1) + '<span class="u">°</span>';
$("sDist").innerHTML = group(st.km) + '<span class="u">km</span>';
$("sSize").innerHTML = (st.semi * 120).toFixed(1) + '<span class="u">′</span>';
/* a note that changes with what you are looking at */
var note;
if (st.k < 0.10) {
note = "<b>That faint disc is real.</b> The dark part is lit by sunlight bouncing off Earth — " +
"earthshine, sometimes called the old moon in the new moon's arms.";
} else if (st.k > 0.985) {
note = "<b>Almost never exactly 100%.</b> The Moon's path is tilted about 5°, so at full it " +
"usually rides a little above or below the straight Sun–Earth line. When it does line up, you get an eclipse.";
} else if (idx === 2 || idx === 6) {
note = "<b>That straight edge is a sunrise line.</b> Along it the Sun sits on the Moon's horizon, " +
"which is why crater shadows there are long enough to see from here.";
} else {
note = "<b>Same face, always.</b> The Moon turns once per orbit, so the pattern of dark seas never " +
"swings away — only the lighting on it moves.";
}
$("note").innerHTML = note;
/* upcoming */
var nf = nextPhase(180, tMs), nn = nextPhase(0, tMs);
$("fullDate").textContent = fmtDate(nf) + " " + fmtTime(nf);
$("fullIn").textContent = "next full moon · " + fmtGap(nf - tMs);
$("newDate").textContent = fmtDate(nn) + " " + fmtTime(nn);
$("newIn").textContent = "next new moon · " + fmtGap(nn - tMs);
jumpFull = nf; jumpNew = nn;
if (full !== false) buildEvents();
/* announce, but not on every frame while playing */
var say = PHASES[idx].n + ", " + Math.round(st.k * 100) + "% lit, " + fmtDate(tMs);
if (say !== lastSpoken && !playing) { lastSpoken = say; $("live").textContent = say; }
}
var jumpFull = 0, jumpNew = 0;
function buildEvents() {
var list = $("evList");
var targets = [[0, "new moon"], [90, "first quarter"], [180, "full moon"], [270, "last quarter"]];
var evs = [];
/* Two turns of each principal phase, so that after dropping the next full and
the next new — already sitting above as the two big buttons — there are
still four genuinely different events left. Listing only one turn each meant
two of the four rows were verbatim copies of the buttons above them. */
for (var i = 0; i < targets.length; i++) {
var t1 = nextPhase(targets[i][0], tMs);
evs.push({ t: t1, n: targets[i][1], a: targets[i][0] });
evs.push({ t: nextPhase(targets[i][0], t1 + 60000), n: targets[i][1], a: targets[i][0] });
}
evs = evs.filter(function (e) {
return Math.abs(e.t - jumpFull) > 60000 && Math.abs(e.t - jumpNew) > 60000;
});
evs.sort(function (a, b) { return a.t - b.t; });
evs = evs.slice(0, 4);
var html = "";
for (var j = 0; j < evs.length; j++) {
html += '<button type="button" class="ev" data-t="' + evs[j].t + '">' +
quarterIcon(evs[j].a) +
'<span class="txt"><span class="t1">' + fmtDate(evs[j].t) + " " + fmtTime(evs[j].t) +
'</span><span class="t2">' + evs[j].n + " · " + fmtGap(evs[j].t - tMs) + "</span></span>" +
'<span class="go">jump →</span></button>';
}
list.innerHTML = '<p class="then">the rest, in order</p>' + html;
}
function quarterIcon(a) {
var s = '<svg class="ic" viewBox="0 0 22 22" aria-hidden="true">';
if (a === 0) {
s += '<circle cx="11" cy="11" r="8.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-dasharray="2.6 2.4"></circle>';
} else if (a === 180) {
s += '<circle cx="11" cy="11" r="9" fill="currentColor"></circle>';
} else {
s += '<circle cx="11" cy="11" r="8.6" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".55"></circle>';
s += a === 90
? '<path d="M11 2.4A8.6 8.6 0 0 1 11 19.6Z" fill="currentColor"></path>'
: '<path d="M11 2.4A8.6 8.6 0 0 0 11 19.6Z" fill="currentColor"></path>';
}
return s + "</svg>";
}
/* ─────────────────────────────────────────────────────────────────────────
10. Controls
───────────────────────────────────────────────────────────────────────── */
function clampT(ms) { return Math.max(winStart, Math.min(tMax, ms)); }
function syncSlider() {
slider.value = String(Math.max(0, Math.min(winDays, Math.round((tMs - winStart) / DAY))));
}
function setT(ms, fromSlider) {
tMs = clampT(ms);
if (!fromSlider) syncSlider();
render();
}
slider.max = String(winDays);
slider.addEventListener("input", function () {
var v = parseInt(slider.value, 10) || 0;
setT(winStart + v * DAY + todMs, true);
});
$("prevDay").addEventListener("click", function () { setT(tMs - DAY); });
$("nextDay").addEventListener("click", function () { setT(tMs + DAY); });
$("todayBtn").addEventListener("click", function () { stop(); setT(Date.now()); });
$("evFull").addEventListener("click", function () { stop(); setT(jumpFull); });
$("evNew").addEventListener("click", function () { stop(); setT(jumpNew); });
$("evList").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button[data-t]") : null;
if (b) { stop(); setT(parseFloat(b.getAttribute("data-t"))); }
});
/* The label IS the state here ("south view" = tap for the southern view), so it
carries no aria-pressed — "north view, pressed" would read as a contradiction. */
$("flip").addEventListener("click", function () {
flipped = !flipped;
this.textContent = flipped ? "north view" : "south view";
this.title = flipped
? "Back to the way the northern half of the world sees it"
: "The southern half of the world sees the same Moon the other way up";
drawDisc();
$("live").textContent = flipped ? "Southern view" : "Northern view";
});
/* scrub the ribbon with a pointer; the slider covers the keyboard */
var scrubbing = false;
function scrub(e) {
var r = ribbon.getBoundingClientRect();
var f = Math.max(0, Math.min(1, (e.clientX - r.left) / Math.max(1, r.width)));
setT(winStart + f * winDays * DAY);
}
ribbon.addEventListener("pointerdown", function (e) {
stop(); scrubbing = true;
if (ribbon.setPointerCapture) { try { ribbon.setPointerCapture(e.pointerId); } catch (err) {} }
scrub(e); e.preventDefault();
});
ribbon.addEventListener("pointermove", function (e) { if (scrubbing) scrub(e); });
ribbon.addEventListener("pointerup", function () { scrubbing = false; });
ribbon.addEventListener("pointercancel", function () { scrubbing = false; });
/* Play. With "reduce motion" on, the control still works but it steps —
one day at a time, twice a second — instead of sweeping continuously. */
var raf = 0, last = 0, tick = 0;
var playBtn = $("playBtn");
function playLabel() { return reduce ? "Step through the year" : "Play the year"; }
function frame(ts) {
if (!playing) return;
if (!last) last = ts;
var dt = Math.min(64, ts - last); last = ts;
/* One lunar month per four seconds, so the whole year plays in about 50s.
(The old rate worked out at three minutes of sky per frame — a year took
three quarters of an hour and the disc looked frozen.) */
tMs += dt * (SYNODIC / 4000) * DAY;
if (tMs > tMax) tMs = winStart + todMs;
syncSlider();
render(false);
raf = requestAnimationFrame(frame);
}
function step() {
if (!playing) return;
tMs += DAY;
if (tMs > tMax) tMs = winStart + todMs;
syncSlider();
render();
}
function play() {
if (playing) return;
playing = true; last = 0;
playBtn.textContent = "Pause";
playBtn.setAttribute("aria-pressed", "true");
if (reduce) tick = setInterval(step, 500);
else raf = requestAnimationFrame(frame);
}
function stop() {
if (!playing) return;
playing = false;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
if (tick) { clearInterval(tick); tick = 0; }
playBtn.textContent = playLabel();
playBtn.setAttribute("aria-pressed", "false");
render();
}
playBtn.textContent = playLabel();
playBtn.addEventListener("click", function () { playing ? stop() : play(); });
/* ─────────────────────────────────────────────────────────────────────────
11. Theme, resize, boot
───────────────────────────────────────────────────────────────────────── */
function redrawAll() { readTheme(); render(false); }
var rt = 0;
window.addEventListener("resize", function () {
clearTimeout(rt);
rt = setTimeout(function () { stripData = null; redrawAll(); }, 90);
});
window.addEventListener("message", function (ev) {
var d = ev.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
redrawAll();
}
});
var mqDark = window.matchMedia("(prefers-color-scheme: dark)");
if (mqDark.addEventListener) mqDark.addEventListener("change", redrawAll);
else if (mqDark.addListener) mqDark.addListener(redrawAll);
function motionChanged() {
reduce = mqMotion.matches;
stop(); /* restart under the new rules if wanted */
playBtn.textContent = playLabel();
}
if (mqMotion.addEventListener) mqMotion.addEventListener("change", motionChanged);
else if (mqMotion.addListener) mqMotion.addListener(motionChanged);
/* The only thing here that can genuinely fail is the 2D context (a locked-down
browser, or a canvas that never got a box). Say so in the frame rather than
leaving three blank rectangles behind a phase name that still updates. */
if (!canDraw) {
[].forEach.call(document.querySelectorAll("canvas"), function (c) { c.style.display = "none"; });
var msg = document.createElement("p");
msg.className = "nocanvas";
msg.textContent = "This browser will not hand the page a drawing canvas, " +
"so the pictures are off. Every date, angle and percentage below is still live.";
document.querySelector(".sky").insertBefore(msg, document.querySelector(".badge"));
$("geo").parentNode.appendChild(msg.cloneNode(true));
}
readTheme();
TEX = buildTexture();
syncSlider();
/* month labels under the slider */
(function () {
var row = $("scaleRow");
var a = new Date(winStart), b = new Date(winEnd);
row.innerHTML = "<span>" + MONTHS[a.getMonth()] + " " + a.getFullYear() + "</span>" +
"<span>" + MONTHS[b.getMonth()] + " " + b.getFullYear() + "</span>";
})();
render();
/* one more pass once fonts and layout have settled */
requestAnimationFrame(function () { stripData = null; render(false); });
})();
</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.