@verifyhash/moon-phase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/index.js +223 -0
  4. package/package.json +39 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 <OWNER-NAME PLACEHOLDER>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # moon-phase
2
+
3
+ <!-- publish-prep -->
4
+ > **TODO (owner): pick the final npm name/scope before publishing.**
5
+ > The npm package name is **not** finalized by this project. Convention used here:
6
+ > `package.json` keeps the working slug `moon-phase` as a placeholder — replace it
7
+ > (and the `npm install` line below) with the real published name/scope before
8
+ > running `npm publish`.
9
+
10
+ A **zero-dependency, pure-function** Node library that turns any instant into the
11
+ Moon's phase: its phase angle, the fraction of the disk that is lit, the synodic
12
+ age in days, the canonical phase name, and the Brown lunation number.
13
+
14
+ No network, no external dependencies, no servers, no I/O. Every export is a pure
15
+ function of a single `Date` and never mutates its argument. `require()` it and go.
16
+
17
+ ```js
18
+ const moon = require('./index.js');
19
+
20
+ const d = new Date('2024-01-25T17:54:00Z'); // a full moon
21
+ moon.phaseName(d); // 'Full'
22
+ moon.illuminatedFraction(d); // ~1.0
23
+ moon.moonAge(d); // ~14.75 (days since the last new moon)
24
+ moon.moonPhaseAngle(d); // ~179.85 (Sun-Moon elongation, degrees)
25
+ moon.lunation(d); // 1250 (Brown Lunation Number)
26
+ ```
27
+
28
+ ## Who it's for
29
+
30
+ Developers who need the Moon's phase without pulling in a heavyweight ephemeris:
31
+
32
+ - **Weather / almanac apps** — show tonight's phase and % illumination next to
33
+ the forecast.
34
+ - **Astronomy & astrophotography planners** — a dark-sky (new moon) or
35
+ full-moon window finder, or a "how washed-out will the sky be" estimate.
36
+ - **Tide and fishing tools** — spring/neap tides track the new/full syzygy, and
37
+ the phase angle is a cheap proxy for it.
38
+ - **Calendar / UI widgets** — a correct moon glyph for any date.
39
+
40
+ It is a *geocentric phase* model: it answers "what phase is the Moon in?", not
41
+ "when does the Moon rise here?" or "where is it in my sky?". For observer-local
42
+ rise/set/altitude you want a different tool (e.g. a full ephemeris).
43
+
44
+ ## API
45
+
46
+ All functions take a JS `Date` interpreted as a **UTC instant** (date *and*
47
+ time). The time of day matters: the Moon moves ~12°/day, so one hour shifts the
48
+ elongation by ~0.5°. A date-only value like `new Date('2024-01-11')` is treated
49
+ as `00:00 UTC` that day.
50
+
51
+ | Function | Returns |
52
+ | --- | --- |
53
+ | `moonPhaseAngle(date)` | Sun-Moon elongation in degrees, `[0, 360)`. 0° = New, 90° = First Quarter, 180° = Full, 270° = Last Quarter. |
54
+ | `illuminatedFraction(date)` | Fraction of the disk lit, `[0, 1]`. 0 at new, 1 at full, ~0.5 at the quarters. |
55
+ | `moonAge(date)` | Days since the last new moon (synodic age), `[0, ~29.53)`. |
56
+ | `phaseName(date)` | One of the 8 canonical phases: `New`, `Waxing Crescent`, `First Quarter`, `Waxing Gibbous`, `Full`, `Waning Gibbous`, `Last Quarter`, `Waning Crescent`. |
57
+ | `lunation(date)` | Brown Lunation Number (integer) of the lunation in progress. |
58
+
59
+ `phaseName` bins the elongation into eight 45°-wide sectors, each centred on its
60
+ defining angle — so `New` covers 337.5°–360°–22.5°, `First Quarter` covers
61
+ 67.5°–112.5°, and so on. The narrow "instant" phases (New, the two quarters,
62
+ Full) are the sectors straddling their exact angles.
63
+
64
+ ## Algorithm & citation
65
+
66
+ The geometry follows **Jean Meeus, *Astronomical Algorithms* (2nd ed., 1998)**:
67
+
68
+ 1. Evaluate the Moon's mean elongation `D`, the Sun's mean anomaly `M`, and the
69
+ Moon's mean anomaly `M′` from the standard polynomials in Julian centuries
70
+ `T` from J2000.0 (Meeus ch. 47).
71
+ 2. Form the true Sun-Moon elongation by adding the leading periodic terms of the
72
+ phase-angle series (Meeus ch. 48, eq. 48.4 — the published "low accuracy"
73
+ method), giving the phase angle `i` and elongation `180° − i`.
74
+ 3. Illuminated fraction `k = (1 + cos i) / 2` (Meeus eq. 48.1).
75
+
76
+ The synodic month is taken as `29.530588853` days (Meeus ch. 47). The Brown
77
+ lunation number is anchored to the new moon of **2000-01-06 18:14 UTC**
78
+ (Meeus lunation index `k = 0`), whose Brown Lunation Number is 953; Brown
79
+ lunation 1 was the new moon of 1923-01-17.
80
+
81
+ ## Accuracy — honest limits
82
+
83
+ This is a **simplified** model. It keeps only the largest periodic terms and no
84
+ planetary perturbations, so:
85
+
86
+ - **Phase angle / elongation:** within about **0.5°** of the full Meeus solution
87
+ for dates within a couple of centuries of J2000.
88
+ - **Illuminated fraction:** within about **1%** — a few percent near a thin
89
+ crescent, where a 0.5° angle error has the most leverage.
90
+ - **Synodic age (`moonAge`):** derived from the true elongation via the *mean*
91
+ synodic month, so it can drift up to roughly **0.5 day** from a true almanac
92
+ age, because the Moon's angular speed varies over its ~27.5-day anomalistic
93
+ (perigee/apogee) cycle. Treat it as good to a *fraction of a day*, not to the
94
+ minute.
95
+ - **No topocentric / parallax correction:** this is the geocentric phase, which
96
+ is what "the phase of the Moon" almost always means.
97
+
98
+ If you need to-the-minute event times (exact new/full moon instants) or a
99
+ sky-position ephemeris, use a full-precision library instead. This one is for
100
+ "what phase, how lit, how old, what's it called" — fast, dependency-free, and
101
+ accurate enough for UI, planning, and forecasting overlays.
102
+
103
+ ## Tests
104
+
105
+ Run the full suite (a tiny built-in assert harness — no framework, no deps):
106
+
107
+ ```
108
+ node test/moon.test.js
109
+ ```
110
+
111
+ It exits non-zero on any failure. Fixtures use published new/full/quarter-moon
112
+ instants (NASA GSFC phase tables / timeanddate.com), and the suite also checks
113
+ that illumination increases monotonically across the waxing half-lunation, that
114
+ the phase-name bins are ordered and complete over a lunation, that inputs are not
115
+ mutated, and that invalid input throws. Stated tolerances: 1.5° on the phase
116
+ angle at a documented event, 0.02 on the illuminated fraction at the quarters,
117
+ and 0.15 day on the synodic age.
118
+
119
+ ## License
120
+
121
+ MIT.
122
+
123
+ ## Install
124
+
125
+ > Placeholder name: the `moon-phase` below is the working slug, not a finalized
126
+ > npm name — see the owner TODO near the top of this README.
127
+
128
+ ```sh
129
+ npm install moon-phase
130
+ ```
131
+
132
+ ```js
133
+ const moon = require('moon-phase');
134
+
135
+ const d = new Date('2024-01-25T17:54:00Z'); // a full moon
136
+ moon.phaseName(d); // 'Full'
137
+ moon.illuminatedFraction(d); // ~1.0
138
+ moon.lunation(d); // 1250 (Brown Lunation Number)
139
+ ```
package/index.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * moon-phase — zero-dependency lunar phase & illumination math.
5
+ *
6
+ * Every export is a PURE function: no I/O, no network, no globals mutated, no
7
+ * argument mutation, no dependencies. It turns any instant (a UTC JS Date) into
8
+ * the Moon's phase angle, illuminated fraction, synodic age, canonical phase
9
+ * name, and Brown lunation number.
10
+ *
11
+ * ALGORITHM
12
+ * The geometry follows Jean Meeus, "Astronomical Algorithms" (2nd ed., 1998),
13
+ * chapter 49 (the mean elongation / mean-anomaly series) and chapter 48's
14
+ * low-accuracy phase-angle formula (48.4). We evaluate the Moon's mean
15
+ * elongation D, the Sun's mean anomaly M, and the Moon's mean anomaly M' from
16
+ * the standard polynomials in Julian centuries T, then add the leading
17
+ * periodic terms to obtain the true Sun-Moon elongation and phase angle. The
18
+ * illuminated fraction is k = (1 + cos i) / 2 where i is the phase angle
19
+ * (Meeus 48.1). This is the same "low accuracy" method NASA and many almanac
20
+ * tools quote as good to a few tenths of a degree in phase angle.
21
+ *
22
+ * ACCURACY (honest limits — this is a SIMPLIFIED model)
23
+ * - Phase angle / elongation: within ~0.5° of the full Meeus solution for
24
+ * dates within a couple of centuries of J2000; it omits the smaller
25
+ * periodic terms and all planetary perturbations.
26
+ * - Illuminated fraction: within ~1% (a few percent near the thin crescent,
27
+ * where a 0.5° angle error matters most).
28
+ * - Synodic age (moonAge): derived from the true elongation via the MEAN
29
+ * synodic month, so it can drift up to ~0.5 day from a true almanac age
30
+ * because the Moon's angular speed varies with its ~27.5-day anomalistic
31
+ * cycle (perigee/apogee). Treat it as good to a fraction of a day, not to
32
+ * the minute.
33
+ * - No topocentric / parallax correction: this is the GEOCENTRIC phase, which
34
+ * is what "the phase of the Moon" almost always means. It is not a rise/set
35
+ * or an observer-altitude calculation.
36
+ *
37
+ * INPUT
38
+ * Every function takes a JS Date interpreted as a UTC INSTANT (date + time).
39
+ * Unlike a daily sunrise calc, the time of day matters here: the Moon moves
40
+ * ~12° per day, so an hour changes the elongation by ~0.5°. Passing a
41
+ * date-only value like new Date('2024-01-11') is treated as 00:00 UTC that
42
+ * day. Inputs are never mutated.
43
+ */
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Constants
47
+ // ---------------------------------------------------------------------------
48
+
49
+ // Mean length of the synodic month (new moon to new moon), in days. Meeus 47.
50
+ const SYNODIC_MONTH = 29.530588853;
51
+
52
+ // Julian Day of the Unix epoch 1970-01-01T00:00:00Z.
53
+ const JULIAN_UNIX_EPOCH = 2440587.5;
54
+ const MS_PER_DAY = 86400000;
55
+
56
+ // Reference new moon used for the Brown lunation number: the new moon of
57
+ // 2000-01-06 at 18:14 UTC has Julian Day ~2451550.1 and is Meeus lunation
58
+ // index k = 0 (Meeus 49.1). The Brown Lunation Number of that new moon is 953,
59
+ // so BLN = floor(k) + 953. (Brown lunation 1 = the new moon of 1923-01-17.)
60
+ const NEWMOON_2000_JD = 2451550.1;
61
+ const BROWN_LUNATION_OFFSET = 953;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const rad = (d) => (d * Math.PI) / 180;
68
+ const deg = (r) => (r * 180) / Math.PI;
69
+
70
+ // Normalise degrees into [0, 360).
71
+ function norm360(x) {
72
+ let r = x % 360;
73
+ if (r < 0) r += 360;
74
+ return r;
75
+ }
76
+
77
+ // Julian Day (including fractional day) for a JS Date instant. Does NOT mutate.
78
+ function julianDay(date) {
79
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
80
+ throw new TypeError('moon-phase: expected a valid JS Date (UTC instant)');
81
+ }
82
+ return date.getTime() / MS_PER_DAY + JULIAN_UNIX_EPOCH;
83
+ }
84
+
85
+ // Meeus fundamental arguments (degrees) at Julian Day jd.
86
+ // Returns { D, M, Mp } — Moon's mean elongation, Sun's mean anomaly, Moon's
87
+ // mean anomaly. Polynomials from Meeus "Astronomical Algorithms" ch. 47.
88
+ function fundamentalArguments(jd) {
89
+ const T = (jd - 2451545.0) / 36525; // Julian centuries from J2000.0
90
+
91
+ const D =
92
+ 297.8501921 +
93
+ 445267.1114034 * T -
94
+ 0.0018819 * T * T +
95
+ (T * T * T) / 545868 -
96
+ (T * T * T * T) / 113065000;
97
+
98
+ const M =
99
+ 357.5291092 +
100
+ 35999.0502909 * T -
101
+ 0.0001536 * T * T +
102
+ (T * T * T) / 24490000;
103
+
104
+ const Mp =
105
+ 134.9633964 +
106
+ 477198.8675055 * T +
107
+ 0.0087414 * T * T +
108
+ (T * T * T) / 69699 -
109
+ (T * T * T * T) / 14712000;
110
+
111
+ return { D: norm360(D), M: norm360(M), Mp: norm360(Mp) };
112
+ }
113
+
114
+ // True Sun-Moon elongation (degrees) at Julian Day jd. This is the geocentric
115
+ // angular distance Moon-from-Sun measured in the direction of the Moon's
116
+ // motion: 0° = new, 90° = first quarter, 180° = full, 270° = last quarter.
117
+ // It is 180° minus the Meeus phase angle (48.4).
118
+ function elongationAtJD(jd) {
119
+ const { D, M, Mp } = fundamentalArguments(jd);
120
+ // Meeus 48.4 gives the phase angle i; elongation = 180 - i, so the sign of
121
+ // every periodic term flips relative to the i-formula.
122
+ const elong =
123
+ D +
124
+ 6.289 * Math.sin(rad(Mp)) -
125
+ 2.100 * Math.sin(rad(M)) +
126
+ 1.274 * Math.sin(rad(2 * D - Mp)) +
127
+ 0.658 * Math.sin(rad(2 * D)) +
128
+ 0.214 * Math.sin(rad(2 * Mp)) +
129
+ 0.110 * Math.sin(rad(D));
130
+ return norm360(elong);
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Public API
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * moonPhaseAngle(date) → the Sun-Moon elongation in degrees, in [0, 360).
139
+ * 0° = New, 90° = First Quarter, 180° = Full, 270° = Last Quarter. This is the
140
+ * "age angle" that drives phaseName() and moonAge().
141
+ */
142
+ function moonPhaseAngle(date) {
143
+ return elongationAtJD(julianDay(date));
144
+ }
145
+
146
+ /**
147
+ * illuminatedFraction(date) → fraction of the Moon's disk that is lit, [0, 1].
148
+ * 0 at new moon, 1 at full moon, ~0.5 at the quarters. Uses k = (1 + cos i)/2
149
+ * with i the phase angle = 180° - elongation (Meeus 48.1).
150
+ */
151
+ function illuminatedFraction(date) {
152
+ const elong = elongationAtJD(julianDay(date));
153
+ const i = 180 - elong; // phase angle in degrees
154
+ const k = (1 + Math.cos(rad(i))) / 2;
155
+ // Guard against tiny floating-point excursions outside [0, 1].
156
+ if (k < 0) return 0;
157
+ if (k > 1) return 1;
158
+ return k;
159
+ }
160
+
161
+ /**
162
+ * moonAge(date) → days since the last new moon (synodic age), in [0, ~29.53).
163
+ * Derived from the true elongation via the mean synodic month, so it is good to
164
+ * a fraction of a day (see ACCURACY note above), not to the minute.
165
+ */
166
+ function moonAge(date) {
167
+ const elong = elongationAtJD(julianDay(date));
168
+ return (elong / 360) * SYNODIC_MONTH;
169
+ }
170
+
171
+ // The 8 canonical phases, in elongation order starting at New. Each occupies a
172
+ // 45°-wide bin centred on its defining angle (New at 0°, First Quarter at 90°,
173
+ // Full at 180°, Last Quarter at 270°). The narrow "instant" phases (New, the
174
+ // quarters, Full) are the bins that straddle those exact angles.
175
+ const PHASE_NAMES = [
176
+ 'New',
177
+ 'Waxing Crescent',
178
+ 'First Quarter',
179
+ 'Waxing Gibbous',
180
+ 'Full',
181
+ 'Waning Gibbous',
182
+ 'Last Quarter',
183
+ 'Waning Crescent',
184
+ ];
185
+
186
+ /**
187
+ * phaseName(date) → one of the 8 canonical phase names (see PHASE_NAMES).
188
+ * Binned by elongation into 45° sectors, so the boundaries between, e.g.,
189
+ * "Waxing Crescent" and "First Quarter" fall at 67.5°, 112.5°, etc.
190
+ */
191
+ function phaseName(date) {
192
+ const elong = elongationAtJD(julianDay(date));
193
+ // Shift by 22.5° so the New bin is centred on 0° (i.e. 337.5°..360°..22.5°).
194
+ const index = Math.floor(norm360(elong + 22.5) / 45) % 8;
195
+ return PHASE_NAMES[index];
196
+ }
197
+
198
+ /**
199
+ * lunation(date) → the Brown Lunation Number of the lunation in progress on the
200
+ * given date (integer). Brown lunation 1 began at the new moon of 1923-01-17;
201
+ * the new moon of 2000-01-06 is number 953. Accurate to the correct lunation
202
+ * for any date within a few centuries of J2000.
203
+ */
204
+ function lunation(date) {
205
+ const jd = julianDay(date);
206
+ // Whole synodic months elapsed since the 2000-01-06 new moon. floor() gives
207
+ // the lunation currently in progress (the count increments at each new moon).
208
+ const k = Math.floor((jd - NEWMOON_2000_JD) / SYNODIC_MONTH);
209
+ return k + BROWN_LUNATION_OFFSET;
210
+ }
211
+
212
+ module.exports = {
213
+ moonPhaseAngle,
214
+ illuminatedFraction,
215
+ moonAge,
216
+ phaseName,
217
+ lunation,
218
+ // constants exposed for advanced use / testing
219
+ SYNODIC_MONTH,
220
+ // internals exposed for testing
221
+ _julianDay: julianDay,
222
+ _elongationAtJD: elongationAtJD,
223
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@verifyhash/moon-phase",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency lunar phase & illumination math (simplified Meeus method): phase angle, illuminated fraction, synodic age, canonical phase name, and Brown lunation number for any UTC instant.",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "test": "node test/moon.test.js"
9
+ },
10
+ "keywords": [
11
+ "moon",
12
+ "lunar",
13
+ "moon-phase",
14
+ "illuminated-fraction",
15
+ "moon-age",
16
+ "synodic",
17
+ "lunation",
18
+ "meeus",
19
+ "astronomy",
20
+ "zero-dependency"
21
+ ],
22
+ "license": "MIT",
23
+ "files": [
24
+ "index.js",
25
+ "README.md"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/verifyhash/libs.git",
33
+ "directory": "moon-phase"
34
+ },
35
+ "homepage": "https://github.com/verifyhash/libs/tree/main/moon-phase#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/verifyhash/libs/issues"
38
+ }
39
+ }