@openwaters/almanac 0.3.0 → 0.4.1

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.
@@ -0,0 +1,66 @@
1
+ import { Observer } from './types.js';
2
+ /** How much of the Sun an observer sees covered: `partial` when the Moon never covers it, `annular` when the Moon sits inside the Sun's disc, `total` when it covers it. */
3
+ export type SolarEclipseKind = 'partial' | 'annular' | 'total';
4
+ /** The Sun's refracted topocentric altitude at each contact, degrees; `null` exactly where the eclipse has no such contact. */
5
+ export interface SolarEclipseSunAltitudes {
6
+ c1: number;
7
+ c2: number | null;
8
+ peak: number;
9
+ c3: number | null;
10
+ c4: number;
11
+ }
12
+ /** A solar eclipse as one observer sees it: peak circumstances plus the contact instants around them. */
13
+ export interface SolarEclipse {
14
+ kind: SolarEclipseKind;
15
+ /** Fraction of the Sun's disc area covered at peak; exactly 1 for a total eclipse. */
16
+ obscuration: number;
17
+ /** First contact: the partial phase begins. */
18
+ c1: Date;
19
+ /** Second contact: the total or annular phase begins — `null` for a partial eclipse. */
20
+ c2: Date | null;
21
+ /** Closest approach of the Moon's shadow axis to the observer. */
22
+ peak: Date;
23
+ /** Third contact: the total or annular phase ends — `null` for a partial eclipse. */
24
+ c3: Date | null;
25
+ /** Fourth contact: the partial phase ends. */
26
+ c4: Date;
27
+ /** The number `sunAltAz` reports at each contact instant, so "above the horizon" agrees with the Sun a consumer draws. */
28
+ sunAltDeg: SolarEclipseSunAltitudes;
29
+ }
30
+ /**
31
+ * The fraction of the Sun's disc area the Moon covers for `observer` at
32
+ * `time`: 0 when the discs are apart, 1 in totality, the ratio of the disc
33
+ * areas in annularity, and the lens overlap between. Purely geometric — it
34
+ * does not test the horizon; a consumer that draws the Sun already knows
35
+ * whether it is up.
36
+ *
37
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
38
+ * @throws {RangeError} if `time` is invalid or `observer` is out of range.
39
+ */
40
+ export declare function solarObscuration(time: Date, observer: Observer): number;
41
+ /**
42
+ * The first solar eclipse `observer` can see whose peak falls strictly after
43
+ * `after`.
44
+ *
45
+ * Every new moon up to the end of the supported interval is tested: the
46
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
47
+ * get a peak-shadow search from the observer. An eclipse whose Sun is below
48
+ * the horizon at C1, the peak, and C4 is skipped.
49
+ *
50
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
51
+ * interval, or if no visible eclipse remains before the end of it.
52
+ * @throws {RangeError} if `after` is invalid or `observer` is out of range.
53
+ */
54
+ export declare function nextSolarEclipse(after: Date, observer: Observer): SolarEclipse;
55
+ /**
56
+ * The last solar eclipse `observer` can see whose peak falls strictly before
57
+ * `before`, skipping peaks within 100 ms of it, just as `nextSolarEclipse`
58
+ * does on the other side.
59
+ *
60
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
61
+ * interval, or if no visible eclipse remains after the start of it.
62
+ * @throws {RangeError} if `before` is invalid or `observer` is out of range.
63
+ */
64
+ export declare function previousSolarEclipse(before: Date, observer: Observer): SolarEclipse;
65
+ /** Solar eclipses `observer` can see with peaks in `[startUtc, endUtc)`, sorted ascending. Contacts may fall outside the window. */
66
+ export declare function solarEclipses(startUtc: Date, endUtc: Date, observer: Observer): SolarEclipse[];
package/dist/solar.js ADDED
@@ -0,0 +1,344 @@
1
+ // L3 solar eclipses for one observer: the Moon's shadow cone against the
2
+ // observer at every new moon — the local peak, the contacts C1–C4 with the
3
+ // Sun's altitude at each, the covered fraction at peak, and the covered
4
+ // fraction of the Sun's disc at any instant.
5
+ //
6
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
7
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
8
+ // AngleBetween (~256), geo_pos (~2236, in transforms.ts),
9
+ // LocalMoonShadow (~8494), PeakLocalMoonShadow (~8587), Obscuration (~8622),
10
+ // SolarEclipseObscuration (~8670), EclipseKindFromUmbra (~8834),
11
+ // local_partial_distance (~9126), local_total_distance (~9130),
12
+ // LocalEclipse (~9137), LocalEclipseTransition (~9165), SunAltitude (~9181)
13
+ // and SearchLocalSolarEclipse (~9211).
14
+ // Constants and operation order are preserved so the Swift port can be a
15
+ // line-for-line translation.
16
+ //
17
+ // Four deliberate departures from upstream, all spec-driven:
18
+ // - the new-moon probe is this package's `searchMoonPhase`, whose longitudes
19
+ // are apparent rather than upstream's geometric (see events.ts). It only
20
+ // seeds a ±0.2 d peak search, so the ~40 s difference cannot change a
21
+ // result.
22
+ // - previous and range searches, the fixed whole-UT-day new-moon seed and
23
+ // the 100 ms same-eclipse band come from the lunar port (eclipse.ts);
24
+ // upstream searches forward only.
25
+ // - the night filter also accepts the Sun above the horizon at the peak, not
26
+ // only at C1 or C4, so a short polar day inside the eclipse is kept.
27
+ // - no two-year scan limit: next/previous walk new moons to the supported
28
+ // boundary. A place can go years without a visible solar eclipse, and a
29
+ // pruned new moon costs one Moon evaluation.
30
+ import { assertObserver, assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
31
+ import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
32
+ import { DEG2RAD, KM_PER_AU, RAD2DEG } from './nutation.js';
33
+ import { moonGeoVectorEqj } from './moon.js';
34
+ import { sunGeoVectorEqj } from './sun.js';
35
+ import { observerGeoVectorEqj, refractionDeg, topoAltAzUnrefracted } from './transforms.js';
36
+ import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './events.js';
37
+ import { calcShadow, moonEclipticLatitudeDeg, PRUNE_LATITUDE_DEG, SAME_ECLIPSE_MS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP } from './eclipse.js';
38
+ /** UPSTREAM: `SUN_RADIUS_AU` and `MOON_POLAR_RADIUS_AU`, astronomy.ts 135 and 149-150. */
39
+ const SUN_RADIUS_AU = SUN_RADIUS_KM / KM_PER_AU;
40
+ const MOON_POLAR_RADIUS_KM = 1736.0;
41
+ const MOON_POLAR_RADIUS_AU = MOON_POLAR_RADIUS_KM / KM_PER_AU;
42
+ /** Upstream's `PeakLocalMoonShadow` window, in days, either side of the new moon. */
43
+ const PEAK_WINDOW_DAYS = 0.2;
44
+ /** Upstream's `LocalEclipse` windows, in days, either side of the peak. */
45
+ const PARTIAL_WINDOW_DAYS = 0.2;
46
+ const TOTAL_WINDOW_DAYS = 0.01;
47
+ /**
48
+ * UPSTREAM: `LocalMoonShadow`, astronomy.ts ~8494 — the Moon's shadow cone
49
+ * evaluated at the observer: the lunacentric observer measured against the
50
+ * heliocentric Moon. All three vectors are EQJ, and `calcShadow` only ever
51
+ * takes dot products and norms of them, so the frame cancels.
52
+ */
53
+ function localMoonShadow(ut, observer) {
54
+ const tt = ttDaysFromUt(ut);
55
+ // Observer's geocentric position.
56
+ const pos = observerGeoVectorEqj(ut, observer);
57
+ // Light-travel and aberration corrected Sun.
58
+ const s = sunGeoVectorEqj(tt);
59
+ // Geocentric Moon.
60
+ const m = moonGeoVectorEqj(tt);
61
+ // Lunacentric location of an observer on the Earth's surface.
62
+ const o = { x: pos.x - m.x, y: pos.y - m.y, z: pos.z - m.z };
63
+ // Convert geocentric moon to heliocentric Moon.
64
+ const hm = { x: m.x - s.x, y: m.y - s.y, z: m.z - s.z };
65
+ return calcShadow(MOON_MEAN_RADIUS_KM, ut, o, hm);
66
+ }
67
+ /** UPSTREAM: `AngleBetween`, astronomy.ts ~256 — degrees. */
68
+ function angleBetweenDeg(a, b) {
69
+ const aa = (a.x * a.x + a.y * a.y + a.z * a.z);
70
+ if (Math.abs(aa) < 1.0e-8)
71
+ throw new Error('almanac internal: AngleBetween first vector is too short');
72
+ const bb = (b.x * b.x + b.y * b.y + b.z * b.z);
73
+ if (Math.abs(bb) < 1.0e-8)
74
+ throw new Error('almanac internal: AngleBetween second vector is too short');
75
+ const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / Math.sqrt(aa * bb);
76
+ if (dot <= -1.0)
77
+ return 180;
78
+ if (dot >= +1.0)
79
+ return 0;
80
+ return RAD2DEG * Math.acos(dot);
81
+ }
82
+ /**
83
+ * UPSTREAM: `Obscuration`, astronomy.ts ~8622 — the area of intersection of
84
+ * two discs of radii `a` and `b` whose centres are `c` apart, divided by the
85
+ * area of the first disc.
86
+ */
87
+ function discOverlap(a, b, c) {
88
+ if (a <= 0.0)
89
+ throw new Error('almanac internal: radius of first disc must be positive');
90
+ if (b <= 0.0)
91
+ throw new Error('almanac internal: radius of second disc must be positive');
92
+ if (c < 0.0)
93
+ throw new Error('almanac internal: distance between discs is not allowed to be negative');
94
+ if (c >= a + b) {
95
+ // The discs are too far apart to have any overlapping area.
96
+ return 0.0;
97
+ }
98
+ if (c == 0.0) {
99
+ // The discs have a common center. Therefore, one disc is inside the other.
100
+ return (a <= b) ? 1.0 : (b * b) / (a * a);
101
+ }
102
+ const x = (a * a - b * b + c * c) / (2 * c);
103
+ const radicand = a * a - x * x;
104
+ if (radicand <= 0.0) {
105
+ // The circumferences do not intersect, or are tangent.
106
+ // We already ruled out the case of non-overlapping discs.
107
+ // Therefore, one disc is inside the other.
108
+ return (a <= b) ? 1.0 : (b * b) / (a * a);
109
+ }
110
+ // The discs overlap fractionally in a pair of lens-shaped areas.
111
+ const y = Math.sqrt(radicand);
112
+ // Return the overlapping fractional area.
113
+ // There are two lens-shaped areas, one to the left of x, the other to the right of x.
114
+ // Each part is calculated by subtracting a triangular area from a sector's area.
115
+ const lens1 = a * a * Math.acos(x / a) - x * y;
116
+ const lens2 = b * b * Math.acos((c - x) / b) - (c - x) * y;
117
+ // Find the fractional area with respect to the first disc.
118
+ return (lens1 + lens2) / (Math.PI * a * a);
119
+ }
120
+ /**
121
+ * UPSTREAM: the body of `SolarEclipseObscuration`, astronomy.ts ~8670, before
122
+ * its clamp — the fraction of the Sun's apparent disc the Moon covers for an
123
+ * observer, from the heliocentric Moon `hm` and the lunacentric observer `lo`.
124
+ */
125
+ function discObscuration(hm, lo) {
126
+ // Find heliocentric observer.
127
+ const ho = { x: hm.x + lo.x, y: hm.y + lo.y, z: hm.z + lo.z };
128
+ // Calculate the apparent angular radius of the Sun for the observer.
129
+ const sunRadius = Math.asin(SUN_RADIUS_AU / Math.hypot(ho.x, ho.y, ho.z));
130
+ // Calculate the apparent angular radius of the Moon for the observer.
131
+ const moonRadius = Math.asin(MOON_POLAR_RADIUS_AU / Math.hypot(lo.x, lo.y, lo.z));
132
+ // Calculate the apparent angular separation between the Sun's center and the Moon's center.
133
+ const sunMoonSeparation = angleBetweenDeg(lo, ho);
134
+ // Find the fraction of the Sun's apparent disc area that is covered by the Moon.
135
+ return discOverlap(sunRadius, moonRadius, sunMoonSeparation * DEG2RAD);
136
+ }
137
+ /**
138
+ * The fraction of the Sun's disc area the Moon covers for `observer` at
139
+ * `time`: 0 when the discs are apart, 1 in totality, the ratio of the disc
140
+ * areas in annularity, and the lens overlap between. Purely geometric — it
141
+ * does not test the horizon; a consumer that draws the Sun already knows
142
+ * whether it is up.
143
+ *
144
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
145
+ * @throws {RangeError} if `time` is invalid or `observer` is out of range.
146
+ */
147
+ export function solarObscuration(time, observer) {
148
+ assertSupported(time);
149
+ assertObserver(observer);
150
+ const shadow = localMoonShadow(utDays(time), observer);
151
+ return discObscuration(shadow.dir, shadow.target);
152
+ }
153
+ /**
154
+ * UPSTREAM: `SolarEclipseObscuration`, astronomy.ts ~8670, with its clamp:
155
+ * "in marginal cases, we need to clamp obscuration to less than 1.0. This
156
+ * function is never called for total eclipses, so it should never return 1.0."
157
+ */
158
+ function solarEclipseObscuration(hm, lo) {
159
+ return Math.min(0.9999, discObscuration(hm, lo));
160
+ }
161
+ /** UPSTREAM: `ShadowDistanceSlope`, astronomy.ts ~8535, bound to `localMoonShadow`. */
162
+ function localShadowSlope(ut, observer) {
163
+ const dt = 1.0 / 86400.0;
164
+ return (localMoonShadow(ut + dt, observer).r - localMoonShadow(ut - dt, observer).r) / dt;
165
+ }
166
+ /**
167
+ * UPSTREAM: `PeakLocalMoonShadow`, astronomy.ts ~8587 — the time near the
168
+ * new moon when the Moon's shadow axis comes closest to the observer, i.e.
169
+ * the ascending zero of the axis distance's time derivative.
170
+ */
171
+ function peakLocalMoonShadow(centerUt, observer) {
172
+ // Use the same new-moon seed regardless of search direction or window.
173
+ // Otherwise a ~1 ms root shift can lose/duplicate a peak when a caller
174
+ // splits a range at a previously returned peak. Only unpruned moons pay
175
+ // for this second phase search; its start is a fixed whole UT day.
176
+ const newmoon = searchMoonPhase(0, Math.floor(centerUt) - 1, 4);
177
+ if (newmoon === null)
178
+ throw new Error('almanac internal: cannot refine new moon');
179
+ const ut = search(u => localShadowSlope(u, observer), newmoon - PEAK_WINDOW_DAYS, newmoon + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak local moon shadow');
180
+ if (ut === null)
181
+ throw new Error('almanac internal: failed to find peak local Moon shadow time');
182
+ return localMoonShadow(ut, observer);
183
+ }
184
+ /**
185
+ * UPSTREAM: `EclipseKindFromUmbra`, astronomy.ts ~8834 — a positive umbra
186
+ * radius at the observer is a total eclipse, otherwise annular. The 14 m
187
+ * bias is upstream's, added to match Espenak's classifications.
188
+ */
189
+ function eclipseKindFromUmbra(k) {
190
+ return (k > 0.014) ? 'total' : 'annular';
191
+ }
192
+ /** UPSTREAM: `local_partial_distance`, astronomy.ts ~9126. */
193
+ function localPartialDistance(shadow) {
194
+ return shadow.p - shadow.r;
195
+ }
196
+ /** UPSTREAM: `local_total_distance`, astronomy.ts ~9130 — `|k|`, because the umbra radius is negative for an annular eclipse. */
197
+ function localTotalDistance(shadow) {
198
+ return Math.abs(shadow.k) - shadow.r;
199
+ }
200
+ /** UPSTREAM: `LocalEclipseTransition`, astronomy.ts ~9165 — the instant `func` crosses zero in `direction` inside `[t1, t2]`. */
201
+ function localEclipseTransition(observer, direction, func, t1, t2) {
202
+ const ut = search(u => direction * func(localMoonShadow(u, observer)), t1, t2, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'local eclipse transition');
203
+ if (ut === null)
204
+ throw new Error('almanac internal: local eclipse transition search failed');
205
+ return ut;
206
+ }
207
+ /**
208
+ * UPSTREAM: `SunAltitude`, astronomy.ts ~9181 — the refracted topocentric
209
+ * altitude `sunAltAz` reports, without its interval assertion: a contact can
210
+ * fall just outside the supported interval while its peak is inside.
211
+ */
212
+ function sunAltDegAt(d, observer) {
213
+ const ut = utDays(d);
214
+ const alt = topoAltAzUnrefracted(sunGeoVectorEqj(ttDaysFromUt(ut)), ut, observer).altDeg;
215
+ return alt + refractionDeg(alt);
216
+ }
217
+ /** UPSTREAM: `LocalEclipse`, astronomy.ts ~9137 — contacts and kind around a peak the observer is inside the penumbra for. */
218
+ function buildSolarEclipse(shadow, observer) {
219
+ const peakUt = shadow.ut;
220
+ const c1Ut = localEclipseTransition(observer, 1, localPartialDistance, peakUt - PARTIAL_WINDOW_DAYS, peakUt);
221
+ const c4Ut = localEclipseTransition(observer, -1, localPartialDistance, peakUt, peakUt + PARTIAL_WINDOW_DAYS);
222
+ let c2Ut = null;
223
+ let c3Ut = null;
224
+ let kind;
225
+ if (shadow.r < Math.abs(shadow.k)) { // take absolute value of 'k' to handle annular eclipses too.
226
+ c2Ut = localEclipseTransition(observer, 1, localTotalDistance, peakUt - TOTAL_WINDOW_DAYS, peakUt);
227
+ c3Ut = localEclipseTransition(observer, -1, localTotalDistance, peakUt, peakUt + TOTAL_WINDOW_DAYS);
228
+ kind = eclipseKindFromUmbra(shadow.k);
229
+ }
230
+ else {
231
+ kind = 'partial';
232
+ }
233
+ const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, shadow.target);
234
+ // Altitudes come from the reported (TimeClip-truncated) instants, so
235
+ // `sunAltAz(e.c1, observer).altDeg === e.sunAltDeg.c1` exactly.
236
+ const c1 = dateFromUt(c1Ut);
237
+ const c2 = c2Ut === null ? null : dateFromUt(c2Ut);
238
+ const peak = dateFromUt(peakUt);
239
+ const c3 = c3Ut === null ? null : dateFromUt(c3Ut);
240
+ const c4 = dateFromUt(c4Ut);
241
+ const alt = (d) => (d === null ? null : sunAltDegAt(d, observer));
242
+ return {
243
+ kind, obscuration, c1, c2, peak, c3, c4,
244
+ sunAltDeg: { c1: alt(c1), c2: alt(c2), peak: alt(peak), c3: alt(c3), c4: alt(c4) }
245
+ };
246
+ }
247
+ /**
248
+ * The night filter: the Sun's centre must be above the horizon at C1, the
249
+ * peak, or C4. Upstream tests only C1 and C4; the peak keeps a short polar day
250
+ * inside the eclipse.
251
+ * ponytail: three samples, not a sunrise search — a day that starts after C1
252
+ * and ends before the peak still drops. Upgrade path: sunEvents over [c1, c4].
253
+ */
254
+ function seesAnyOfIt(e) {
255
+ return e.sunAltDeg.c1 > 0.0 || e.sunAltDeg.peak > 0.0 || e.sunAltDeg.c4 > 0.0;
256
+ }
257
+ /**
258
+ * The first solar eclipse `observer` can see whose peak falls strictly after
259
+ * `after`.
260
+ *
261
+ * Every new moon up to the end of the supported interval is tested: the
262
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
263
+ * get a peak-shadow search from the observer. An eclipse whose Sun is below
264
+ * the horizon at C1, the peak, and C4 is skipped.
265
+ *
266
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
267
+ * interval, or if no visible eclipse remains before the end of it.
268
+ * @throws {RangeError} if `after` is invalid or `observer` is out of range.
269
+ */
270
+ export function nextSolarEclipse(after, observer) {
271
+ return nearestSolarEclipse(after, 1, observer);
272
+ }
273
+ /**
274
+ * The last solar eclipse `observer` can see whose peak falls strictly before
275
+ * `before`, skipping peaks within 100 ms of it, just as `nextSolarEclipse`
276
+ * does on the other side.
277
+ *
278
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
279
+ * interval, or if no visible eclipse remains after the start of it.
280
+ * @throws {RangeError} if `before` is invalid or `observer` is out of range.
281
+ */
282
+ export function previousSolarEclipse(before, observer) {
283
+ return nearestSolarEclipse(before, -1, observer);
284
+ }
285
+ /** Solar eclipses `observer` can see with peaks in `[startUtc, endUtc)`, sorted ascending. Contacts may fall outside the window. */
286
+ export function solarEclipses(startUtc, endUtc, observer) {
287
+ assertSupported(startUtc);
288
+ assertSupportedWindowEnd(endUtc);
289
+ assertObserver(observer);
290
+ return scanSolarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false, observer);
291
+ }
292
+ function nearestSolarEclipse(anchor, direction, observer) {
293
+ assertSupported(anchor);
294
+ assertObserver(observer);
295
+ const ms = anchor.getTime();
296
+ // No scan limit: walk to the supported boundary. A place can go years
297
+ // without a visible solar eclipse, and a pruned new moon is cheap.
298
+ const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : SUPPORTED_MIN;
299
+ const endMs = direction > 0 ? SUPPORTED_MAX : ms - SAME_ECLIPSE_MS;
300
+ const found = scanSolarEclipses(startMs, endMs, direction, true, observer);
301
+ if (found.length)
302
+ return found[0];
303
+ throw new AlmanacOutOfRangeError();
304
+ }
305
+ function scanSolarEclipses(startMs, endMs, direction, firstOnly, observer) {
306
+ const found = [];
307
+ if (startMs >= endMs)
308
+ return found;
309
+ // Peak and new moon differ by up to the peak window. Include the entire
310
+ // margin at both ends, then apply the caller's bounds to the reported peak.
311
+ const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
312
+ const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
313
+ let nmUt = direction > 0 ? startUt : endUt;
314
+ const limitUt = direction > 0 ? endUt : startUt;
315
+ while (direction * (limitUt - nmUt) > 0) {
316
+ const newmoon = searchMoonPhase(0, nmUt, direction * Math.min(40, Math.abs(limitUt - nmUt)));
317
+ if (newmoon === null)
318
+ break;
319
+ // UPSTREAM `SearchLocalSolarEclipse`: step past this new moon before
320
+ // the next probe, so the same one cannot be found twice.
321
+ nmUt = newmoon + direction * 10;
322
+ // Pruning: if the new moon's ecliptic latitude is too large, a solar
323
+ // eclipse is not possible.
324
+ if (Math.abs(moonEclipticLatitudeDeg(newmoon)) >= PRUNE_LATITUDE_DEG)
325
+ continue;
326
+ // Search near the new moon for the time when the observer is closest
327
+ // to the line passing through the centers of the Sun and Moon.
328
+ const shadow = peakLocalMoonShadow(newmoon, observer);
329
+ if (shadow.r >= shadow.p)
330
+ continue; // the observer never enters the penumbra
331
+ const peak = dateFromUt(shadow.ut);
332
+ if (peak.getTime() < startMs || peak.getTime() >= endMs)
333
+ continue;
334
+ // This is at least a partial solar eclipse for the observer.
335
+ const eclipse = buildSolarEclipse(shadow, observer);
336
+ // Ignore any eclipse that happens completely at night.
337
+ if (!seesAnyOfIt(eclipse))
338
+ continue;
339
+ found.push(eclipse);
340
+ if (firstOnly)
341
+ break;
342
+ }
343
+ return found;
344
+ }
@@ -23,6 +23,15 @@ export interface TopoUnrefracted extends AltAz {
23
23
  * are folded into one multiply here — same value, one division dropped.
24
24
  */
25
25
  export declare function siderealDeg(ut: number): number;
26
+ /**
27
+ * UPSTREAM: `geo_pos`, astronomy.ts ~2236 — the observer's geocentric
28
+ * position in AU on the J2000 mean equator: `terra`'s of-date vector
29
+ * gyrated into J2000. The topocentric path below avoids this rotation; the
30
+ * solar-eclipse layer needs it because it subtracts the observer from a
31
+ * geocentric Moon that stays in EQJ.
32
+ * INTERNAL: exported for `solar.ts`, not part of the public API.
33
+ */
34
+ export declare function observerGeoVectorEqj(ut: number, observer: Observer): Vec3;
26
35
  /**
27
36
  * UPSTREAM: the topocentric path composed of `Equator(body, date, observer,
28
37
  * ofdate=true, aberration=true)` (astronomy.ts ~2803) followed by `Horizon`
@@ -81,6 +81,17 @@ function observerGeoVectorOfDate(gastDeg, observer) {
81
81
  z: ash * sinphi / KM_PER_AU
82
82
  };
83
83
  }
84
+ /**
85
+ * UPSTREAM: `geo_pos`, astronomy.ts ~2236 — the observer's geocentric
86
+ * position in AU on the J2000 mean equator: `terra`'s of-date vector
87
+ * gyrated into J2000. The topocentric path below avoids this rotation; the
88
+ * solar-eclipse layer needs it because it subtracts the observer from a
89
+ * geocentric Moon that stays in EQJ.
90
+ * INTERNAL: exported for `solar.ts`, not part of the public API.
91
+ */
92
+ export function observerGeoVectorEqj(ut, observer) {
93
+ return gyration(observerGeoVectorOfDate(siderealDeg(ut), observer), ttDaysFromUt(ut), PrecessDirection.Into2000);
94
+ }
84
95
  /** UPSTREAM: `spin`, astronomy.ts ~2518 — rotate a horizontal-frame unit vector by sidereal angle. */
85
96
  function spin(angleDeg, pos) {
86
97
  const angr = angleDeg * DEG2RAD;
package/package.json CHANGED
@@ -1,16 +1,50 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.3.0",
4
- "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar eclipses.",
3
+ "version": "0.4.1",
4
+ "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar and solar eclipses.",
5
5
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/openwatersio/almanac.git", "directory": "typescript" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/openwatersio/almanac.git",
9
+ "directory": "typescript"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/openwatersio/almanac/issues"
13
+ },
14
+ "keywords": [
15
+ "astronomy",
16
+ "sun",
17
+ "moon",
18
+ "ephemeris",
19
+ "lunar-eclipse",
20
+ "solar-eclipse",
21
+ "marine"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
7
26
  "homepage": "https://openwaters.io/sky",
8
27
  "type": "module",
9
28
  "main": "./dist/index.js",
10
29
  "types": "./dist/index.d.ts",
11
- "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
12
- "files": ["dist", "NOTICE"],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "NOTICE"
39
+ ],
13
40
  "//": "index.ts is a curated allow-list: ONLY the spec's public API table is exported — no guards, no L0/L1 internals",
14
- "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run", "prepack": "npm run build && cp ../NOTICE ../LICENSE ../README.md ." },
15
- "devDependencies": { "typescript": "^5.5.0", "vitest": "^2.0.0" }
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "test": "vitest run",
44
+ "prepack": "npm run build && cp ../NOTICE ../LICENSE ../README.md ."
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.0"
49
+ }
16
50
  }