@tubular/astronomy
Version:
Astronomical calculations for planetary positions, moon phases, eclipses, rise, transit, and set times, and more.
993 lines • 62.2 kB
JavaScript
// noinspection CommaExpressionJS
import { abs, Angle, div_rd, floor, FMT_DD, FMT_MINS, max, min, MinMaxFinder, mod, mod2, round, sign, sin_deg, Unit, ZeroFinder } from '@tubular/math';
import { Calendar, DateTime, DateTimeField, getISOFormatDate, Timezone, utToTdt } from '@tubular/time';
import { flatten, htmlEscape, isNumber, isString, processMillis } from '@tubular/util';
import { APHELION, AVG_SUN_MOON_RADIUS, FALL_EQUINOX, FIRST_QUARTER, FULL_MOON, GALILEAN_MOON_EVENT, GREATEST_ELONGATION, GRS_TRANSIT_EVENT, HALF_DAY, HALF_MINUTE, INFERIOR_CONJUNCTION, LAST_QUARTER, LUNAR_ECLIPSE, LUNAR_ECLIPSE_LOCAL, MARS, MAX_ALT_FOR_TWILIGHT, MEAN_JUPITER_SYS_II, MEAN_SYNODIC_MONTH, MERCURY, MINUTE, MOON, NAUTICAL_TWILIGHT, NEPTUNE, NEW_MOON, NON_EVENT, OPPOSITION, PERIHELION, PHASE_EVENT_BASE, QUADRATURE, QUICK_PLANET, REFRACTION_AT_HORIZON, RISE_EVENT, SET_EVENT, SET_EVENT_MINUS_1_MIN, SIGNED_HOUR_ANGLE, SOLAR_ECLIPSE, SOLAR_ECLIPSE_LOCAL, SPRING_EQUINOX, SUMMER_SOLSTICE, SUN, SUPERIOR_CONJUNCTION, TRANSIT_EVENT, TWILIGHT_BEGINS, TWILIGHT_ENDS, UNSEEN_ALL_DAY, URANUS, VENUS, VISIBLE_ALL_DAY, WINTER_SOLSTICE } from './astro-constants';
import { JupitersMoons } from './jupiter-moons';
import { SolarSystem, CircumstancesOfEclipse } from './solar-system';
/* eslint-disable no-case-declarations, yoda */
export class AstroEvent {
constructor(eventType, eventText, year, month, day, hourOffset, zone, gregorianChange, value) {
this._eventType = eventType;
this._eventText = eventText;
this._value = value;
this.eventTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
const minutesInDay = round(this.eventTime.getMinutesInDay(year, month, day));
const minutesIntoDay = min(max(floor(hourOffset * 60), 0), minutesInDay - 1);
this._jdu = this.eventTime.wallTime.jdu + hourOffset / 24;
this.eventTime.add(DateTimeField.MINUTE, minutesIntoDay);
}
static fromJdu(eventType, eventText, jdu, zone, gregorianChange, value) {
const dateTime = new DateTime(DateTime.millisFromJulianDay(jdu), zone, gregorianChange);
const startOfDay = dateTime.getStartOfDayMillis();
const ymd = dateTime.wallTime;
const hourOffset = (dateTime.utcTimeMillis - startOfDay) / 3600000;
return new AstroEvent(eventType, eventText, ymd.y, ymd.m, ymd.d, hourOffset, zone, gregorianChange, value);
}
get eventType() { return this._eventType; }
get eventText() { return this._eventText; }
get value() { return this._value; }
get ut() { return DateTime.julianDay(this.eventTime.utcTimeMillis); }
get jdu() { var _a; return (_a = this._jdu) !== null && _a !== void 0 ? _a : this.ut; }
toString() {
return this._eventType + '; ' + this._eventText + '; ' + this.eventTime.toYMDhmString() +
(this.value == null ? '' : '; ' + this.value) +
(isString(this.miscInfo) ? '; ' + this.miscInfo : '');
}
}
function esc(s) {
return htmlEscape(s).replace(/\n/g, '<br>');
}
export class EventFinder {
// eslint-disable-next-line no-useless-constructor
constructor(jupiterInfo) {
this.jupiterInfo = jupiterInfo;
this.ss = new SolarSystem();
this.jupitersMoons = new JupitersMoons();
}
getLunarPhaseEvent(year, month, day, zone, gregorianChange) {
if (!zone)
zone = Timezone.UT_ZONE;
const dateTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
let startOfDay = DateTime.julianDay(dateTime.utcTimeMillis) - HALF_MINUTE;
const minutesInDay = dateTime.getMinutesInDay(year, month, day);
if (minutesInDay === 0)
return null;
let endOfDay = startOfDay + minutesInDay * MINUTE;
startOfDay = utToTdt(startOfDay);
endOfDay = utToTdt(endOfDay);
let lowPhase = this.ss.getLunarPhase(startOfDay);
let highPhase = this.ss.getLunarPhase(endOfDay);
let angle;
let eventTime = startOfDay + HALF_MINUTE;
let gotEvent = false;
let phaseIndex = -1;
// Make sure lowPhase < highPhase when 0-degree point is in between the two.
if (lowPhase > 315)
lowPhase -= 360;
if (highPhase > 315)
highPhase -= 360;
do {
++phaseIndex;
angle = phaseIndex * 90;
// Does the magic moment of one of the enumerated phases occur
// between the start and end of this day?
if (lowPhase <= angle && angle < highPhase) {
gotEvent = true;
const zeroFinder = new ZeroFinder((x) => {
return mod2(this.ss.getLunarPhase(x) - angle, 360);
}, 0.0001, 6, startOfDay, lowPhase - angle, endOfDay, highPhase - angle);
eventTime = (zeroFinder.getXAtZero() - startOfDay) * 24;
}
} while (phaseIndex < 3 && !gotEvent);
if (!gotEvent)
return null;
dateTime.add(DateTimeField.MINUTE, floor(eventTime));
return new AstroEvent(NEW_MOON + phaseIndex, ['new moon', '1st quarter', 'full moon', 'third quarter'][phaseIndex], year, month, day, eventTime, zone);
}
getLunarPhasesByYear(startYear, endYear, zone, gregorianChange, addPaddingMonths = false) {
if (!zone)
zone = Timezone.UT_ZONE;
const results = [];
const dateTime = new DateTime(null, zone);
const δ = (addPaddingMonths ? 1 : 0);
const lastMonthYear = (endYear + 1) * 12 + δ;
let monthYear = startYear * 12 - δ;
let checkPhase = 0;
let event;
const calculate = () => {
const startTick = Date.now();
for (; monthYear < lastMonthYear && Date.now() < startTick + 50; ++monthYear) {
const year = div_rd(monthYear, 12);
const month = mod(monthYear, 12) + 1;
const firstDay = dateTime.getFirstDateInMonth(year, month);
const lastDay = dateTime.getLastDateInMonth(year, month);
const missing = dateTime.getMissingDateRange(year, month);
for (let day = firstDay; day <= lastDay; ++day) {
if (missing && missing[0] <= day && day <= missing[1])
continue;
if (checkPhase > 0)
--checkPhase;
else {
event = this.getLunarPhaseEvent(year, month, day, zone, gregorianChange);
if (event) {
// No sense calculating phase data again for at least 4 days.
if (!missing)
checkPhase = 4;
results.push(event);
}
}
}
}
};
return new Promise((resolve) => {
const loop = () => {
calculate();
if (monthYear === lastMonthYear)
resolve(results);
else
setTimeout(loop);
};
setTimeout(loop);
});
}
static formatEventDate(event) {
return getISOFormatDate(event.eventTime.wallTime);
}
static formatEventTime(event) {
const hour = event.eventTime.wallTime.hrs;
const minute = event.eventTime.wallTime.min;
let dstSymbol = Timezone.getDstSymbol(event.eventTime.wallTime.dstOffset);
if (!dstSymbol)
dstSymbol = ' ';
return (hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') + minute + dstSymbol;
}
static formatEventDateTime(event) {
return EventFinder.formatEventDate(event) + ' ' + EventFinder.formatEventTime(event);
}
static formatEventDateTimeWithoutYear(event) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = event.eventTime.wallTime.m;
const day = event.eventTime.wallTime.d;
return months[month - 1] + ' ' + (day < 10 ? ' ' : '') + day + ' ' + EventFinder.formatEventTime(event);
}
getLunarPhasesByYearAsHtml(startYear, endYear, zone, gregorianChange, options) {
return this.getLunarPhasesByYear(startYear, endYear, zone, gregorianChange).then(events => {
const results = [];
let headers = ['New', 'First Quarter', 'Full', 'Last Quarter'];
let lastYear = -Number.MAX_VALUE;
let col = 0;
if (options && options.tableClass)
results.push(`<table class="${options.tableClass}">\n`);
else
results.push('<table>\n');
let formatDateTime = EventFinder.formatEventDateTimeWithoutYear;
if (options && options.formatDateTime)
formatDateTime = options.formatDateTime;
let formatYear = (year) => year.toString();
if (options && options.formatYear)
formatYear = options.formatYear;
if (options && options.headers)
headers = options.headers;
results.push(' <tr>\n');
results.push(` <th> </th><th>${esc(headers[0])}</th><th>${esc(headers[1])}</th><th>${esc(headers[2])}</th><th>${esc(headers[3])}</th>\n`);
results.push(' </tr>\n');
events.forEach(event => {
const year = event.eventTime.wallTime.y;
const formattedDate = formatDateTime(event);
// eslint-disable-next-line no-unmodified-loop-condition
while (col > 0 && year > lastYear) {
results.push('<td> </td>');
if (++col > 4) {
results.push('\n </tr>\n');
col = 0;
}
}
if (col === 0) {
results.push(' <tr>\n <td>');
++col;
if (year > lastYear) {
results.push(formatYear(year));
lastYear = year;
}
else
results.push(' ');
results.push('</td>');
}
while (event.eventType - PHASE_EVENT_BASE + 1 > col) {
results.push('<td> </td>');
++col;
}
results.push(`<td>${formattedDate}</td>`);
++col;
if (col > 4) {
results.push('\n </tr>\n');
col = 0;
}
});
if (col > 0) {
while (++col <= 5)
results.push('<td> </td>');
results.push('\n </tr>\n');
}
results.push('</table>\n');
return Promise.resolve(results.join(''));
});
}
getLunarPhasesForMonth(year, month, zone, gregorianChange) {
if (!zone)
zone = Timezone.UT_ZONE;
const results = [];
const dateTime = new DateTime(null, zone);
const firstDay = dateTime.getFirstDateInMonth(year, month);
const lastDay = dateTime.getLastDateInMonth(year, month);
const missing = dateTime.getMissingDateRange(year, month);
let checkPhase = 0;
for (let day = firstDay; day <= lastDay; ++day) {
if (missing && missing[0] <= day && day <= missing[1])
continue;
if (checkPhase > 0)
--checkPhase;
else {
const event = this.getLunarPhaseEvent(year, month, day, zone, gregorianChange);
if (event) {
// No sense calculating phase data again for at least 4 days.
if (!missing)
checkPhase = 4;
results.push(event);
}
}
}
return results;
}
getEquinoxSolsticeEvent(year, month, day, zone, gregorianChange) {
if (month % 3 !== 0 && year > -500 && year < 2700)
return null;
if (!zone)
zone = Timezone.UT_ZONE;
const dateTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
let startOfDay = DateTime.julianDay(dateTime.utcTimeMillis) - HALF_MINUTE;
const minutesInDay = dateTime.getMinutesInDay(year, month, day);
if (minutesInDay === 0)
return null;
let endOfDay = startOfDay + minutesInDay * MINUTE;
startOfDay = utToTdt(startOfDay);
endOfDay = utToTdt(endOfDay);
let lowLongitude = this.ss.getEclipticPosition(SUN, startOfDay).longitude.degrees;
let highLongitude = this.ss.getEclipticPosition(SUN, endOfDay).longitude.degrees;
let angle;
let eventTime = 0;
let gotEvent = false;
let eventIndex = -1;
// Make sure lowLongitude < highLongitude when 0-degree point is in between the two.
if (lowLongitude > 315)
lowLongitude -= 360;
if (highLongitude > 315)
highLongitude -= 360;
do {
++eventIndex;
angle = eventIndex * 90;
// Does the magic moment of one of the equinoxes or solstices occur
// between the start and end of this day?
if (lowLongitude <= angle && angle < highLongitude) {
gotEvent = true;
const zeroFinder = new ZeroFinder((x) => {
return mod2(this.ss.getEclipticPosition(SUN, x).longitude.degrees - angle, 360);
}, 0.00001, 6, startOfDay, lowLongitude - angle, endOfDay, highLongitude - angle);
eventTime = (zeroFinder.getXAtZero() - startOfDay) * 24;
}
} while (eventIndex < 3 && !gotEvent);
if (!gotEvent)
return null;
return new AstroEvent(SPRING_EQUINOX + eventIndex, ['vernal equinox', 'summer solstice', 'autumnal equinox', 'winter solstice'][eventIndex], year, month, day, eventTime, zone, gregorianChange);
}
getEquinoxesAndSolsticesForOneYear(year, zone, gregorianChange) {
const results = [];
const dateTime = new DateTime(null, zone || Timezone.UT_ZONE, gregorianChange);
let firstMonth = 3;
let step = 3;
if (year < -500 || year > 2700) {
firstMonth = 1;
step = 1;
}
for (let month = firstMonth; month <= 12; month += step) {
const firstDay = dateTime.getFirstDateInMonth(year, month);
const lastDay = dateTime.getLastDateInMonth(year, month);
for (let day = firstDay; day <= lastDay; ++day) {
if (dateTime.isValidDate(year, month, day)) {
const event = this.getEquinoxSolsticeEvent(year, month, day, zone);
if (event !== null) {
results.push(event);
break;
}
}
}
}
return results;
}
getEquinoxesAndSolsticesByYear(startYear, endYear, zone, gregorianChange) {
const results = [];
const dateTime = new DateTime(null, zone || Timezone.UT_ZONE, gregorianChange);
const lastMonthYear = (endYear + 1) * 12;
let monthYear = startYear * 12;
const calculate = () => {
const startTick = Date.now();
for (; monthYear < lastMonthYear && Date.now() < startTick + 50; ++monthYear) {
const year = div_rd(monthYear, 12);
const month = mod(monthYear, 12) + 1;
// Over a safe range of years even under the Julian calendar the equinoxes and solstices will
// stay in months divisible by three.
if (-500 <= year && year <= 2700 && month % 3 !== 0)
continue;
const firstDay = dateTime.getFirstDateInMonth(year, month);
const lastDay = dateTime.getLastDateInMonth(year, month);
for (let day = firstDay; day <= lastDay; ++day) {
if (dateTime.isValidDate(year, month, day)) {
const event = this.getEquinoxSolsticeEvent(year, month, day, zone);
if (event !== null) {
results.push(event);
break;
}
}
}
}
};
return new Promise((resolve) => {
const loop = () => {
calculate();
if (monthYear === lastMonthYear)
resolve(results);
else
setTimeout(loop);
};
setTimeout(loop);
});
}
getEquinoxesAndSolsticesByYearAsHtml(startYear, endYear, zone, gregorianChange, options) {
return this.getEquinoxesAndSolsticesByYear(startYear, endYear, zone, gregorianChange).then(events => {
const results = [];
let headers = ['Spring\nEquinox', 'Summer\nSolstice', 'Fall\nEquinox', 'Winter\nSolstice'];
let col = 0;
if (options && options.tableClass)
results.push(`<table class="${options.tableClass}">\n`);
else
results.push('<table>\n');
let formatDateTime = EventFinder.formatEventDateTimeWithoutYear;
if (options && options.formatDateTime)
formatDateTime = options.formatDateTime;
let formatYear = (year) => year.toString();
if (options && options.formatYear)
formatYear = options.formatYear;
if (options && options.headers)
headers = options.headers;
results.push(' <tr>\n');
if (options && options.southernHemisphere)
results.push(` <th> </th><th>${esc(headers[2])}</th><th>${esc(headers[3])}</th><th>${esc(headers[0])}</th><th>${esc(headers[1])}</th>\n`);
else
results.push(` <th> </th><th>${esc(headers[0])}</th><th>${esc(headers[1])}</th><th>${esc(headers[2])}</th><th>${esc(headers[3])}</th>\n`);
results.push(' </tr>\n');
events.forEach(event => {
const year = event.eventTime.wallTime.y;
const formattedDate = formatDateTime(event);
if (col === 0) {
++col;
results.push(' <td>');
results.push(formatYear(year));
results.push('</td>');
}
results.push(`<td>${formattedDate}</td>`);
++col;
if (col > 4) {
results.push('\n </tr>\n');
col = 0;
}
});
results.push('</table>\n');
return Promise.resolve(results.join(''));
});
}
// This method breaks a day up into segments and looks for segments during which the local
// altitude of a body passes through the target altitude for rise/set times (or twilight
// start/end times). Situations such as no rising, no setting, no rising or setting, or
// even two risings or two settings during a single day are handled.
//
getRiseAndSetTimes(body, year, month, day, observer, zone, gregorianChange, minutesBefore = 0, targetAltitude, doTwilight) {
if (!zone)
zone = Timezone.UT_ZONE;
if (targetAltitude == null) {
targetAltitude = -REFRACTION_AT_HORIZON;
if (body === SUN || body === MOON)
targetAltitude -= AVG_SUN_MOON_RADIUS;
}
if (doTwilight == null)
doTwilight = (body === SUN && targetAltitude <= MAX_ALT_FOR_TWILIGHT);
const results = [];
const dateTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
let startOfDay = DateTime.julianDay(dateTime.utcTimeMillis) - HALF_MINUTE;
const minutesInDay = dateTime.getMinutesInDay(year, month, day);
if (minutesInDay === 0)
return results;
const dayLength = minutesInDay / 1440;
let segments = 6;
let subsegments;
if (body === MOON)
segments *= 2;
if (abs(observer.latitude.degrees) > 60)
segments *= 2;
startOfDay += minutesBefore / 1440;
let startTime = startOfDay;
let startAltitude = this.ss.getHorizontalPosition(body, startTime, observer).altitude.degrees;
let endTime;
let endAltitude;
let savedEndAltitude;
let middayAltitude = -90;
let eventTime;
let eventType;
let eventText;
for (let i = 1; i <= segments; ++i) {
if (i === segments / 2)
middayAltitude = startAltitude;
endTime = startOfDay + i / segments * dayLength;
endAltitude = this.ss.getHorizontalPosition(body, endTime, observer).altitude.degrees;
savedEndAltitude = endAltitude;
// If the body seems to be skimming the horizon (or other target altitude)
// we'll need to break this segment into subsegments.
if ((abs(startAltitude - targetAltitude) < 1 ||
abs(endAltitude - targetAltitude) < 1) &&
abs(startAltitude - endAltitude) < 2)
subsegments = 10;
else
subsegments = 1;
for (let j = 1; j <= subsegments; ++j) {
if (subsegments > 1) {
if (j < subsegments) {
endTime = startOfDay + ((i - 1) + j / subsegments) / segments * dayLength;
endAltitude = this.ss.getHorizontalPosition(body, endTime, observer).altitude.degrees;
}
else {
endTime = startOfDay + i / segments * dayLength;
endAltitude = savedEndAltitude;
}
}
// Is the target altitude in between the start and end altitudes?
if ((startAltitude <= targetAltitude &&
targetAltitude < endAltitude) ||
(endAltitude < targetAltitude && targetAltitude <= startAltitude)) {
if (startAltitude < endAltitude) {
eventType = (doTwilight ? TWILIGHT_BEGINS : RISE_EVENT);
eventText = (doTwilight ? 'twilight begins' : 'rise');
}
else {
eventType = (doTwilight ? TWILIGHT_ENDS : (minutesBefore !== 0 ? SET_EVENT_MINUS_1_MIN : SET_EVENT));
eventText = (doTwilight ? 'twilight ends' : (minutesBefore !== 0 ? 'set - 1' : 'set'));
}
const zeroFinder = new ZeroFinder((x) => {
return this.ss.getHorizontalPosition(body, x, observer).altitude.degrees - targetAltitude;
}, 0.001, 8, startTime, startAltitude - targetAltitude, endTime, endAltitude - targetAltitude);
eventTime = zeroFinder.getXAtZero();
const rsTime = (eventTime - startOfDay) * 24;
results.push(new AstroEvent(eventType, eventText, year, month, day, rsTime, zone, gregorianChange));
}
startTime = endTime;
startAltitude = endAltitude;
}
}
if (!doTwilight && results.length === 0) {
if (middayAltitude > targetAltitude)
results.push(new AstroEvent(VISIBLE_ALL_DAY, 'visible all day', year, month, day, 0, zone, gregorianChange));
else
results.push(new AstroEvent(UNSEEN_ALL_DAY, 'unseen all day', year, month, day, 0, zone, gregorianChange));
}
return results;
}
getTransitTimes(body, year, month, day, observer, zone, gregorianChange) {
if (!zone)
zone = Timezone.UT_ZONE;
const results = [];
const dateTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
const startOfDay = DateTime.julianDay(dateTime.utcTimeMillis) - HALF_MINUTE;
const minutesInDay = dateTime.getMinutesInDay(year, month, day);
if (minutesInDay === 0)
return results;
let minAltitude = -0.5833;
if (body === SUN || body === MOON)
minAltitude = -0.8333;
const dayLength = minutesInDay / 1440;
const segments = 5;
let startTime = startOfDay;
let startAngle = this.ss.getHourAngle(body, startTime, observer, SIGNED_HOUR_ANGLE).radians;
let endTime;
let endAngle;
let eventTime;
for (let i = 1; i <= segments; ++i) {
endTime = startOfDay + i / segments * dayLength;
endAngle = this.ss.getHourAngle(body, endTime, observer, SIGNED_HOUR_ANGLE).radians;
// No angle change? Too close to a pole -- bail out.
if (startAngle === endAngle)
break;
// Is an hour angle of zero between the start and end angles?
if (startAngle <= 0 && 0 < endAngle) {
const zeroFinder = new ZeroFinder((x) => {
return this.ss.getHourAngle(body, x, observer, SIGNED_HOUR_ANGLE).radians;
}, 0.0001, 8, startTime, startAngle, endTime, endAngle);
eventTime = zeroFinder.getXAtZero();
// Accept the event only if the object is above the horizon at the time
if (this.ss.getHorizontalPosition(body, eventTime, observer).altitude.degrees >= minAltitude) {
const transitTime = (eventTime - startOfDay) * 24;
results.push(new AstroEvent(TRANSIT_EVENT, 'transit', year, month, day, transitTime, zone, gregorianChange));
}
}
startTime = endTime;
startAngle = endAngle;
}
return results;
}
getMinutesOfDaylight(year, month, day, observer, zone, gregorianChange) {
const sunEvents = this.getRiseAndSetTimes(SUN, year, month, day, observer, zone, gregorianChange);
if (sunEvents.length === 1 && sunEvents[0].eventType === UNSEEN_ALL_DAY)
return 0;
const dateTime = new DateTime({ y: year, m: month, d: day, hrs: 0, min: 0, sec: 0, occurrence: 1 }, zone, gregorianChange);
const minutesInDay = dateTime.getMinutesInDay(year, month, day);
if (sunEvents.length === 1 && sunEvents[0].eventType === VISIBLE_ALL_DAY)
return minutesInDay;
const startOfDay = DateTime.julianDay(dateTime.utcTimeMillis);
let lastTime = startOfDay;
let total = 0;
let lastEvent = NON_EVENT;
sunEvents.forEach(event => {
if (event.eventType === RISE_EVENT) {
lastEvent = RISE_EVENT;
lastTime = event.ut;
}
else if (event.eventType === SET_EVENT) {
total += event.ut - lastTime;
lastEvent = SET_EVENT;
}
});
if (lastEvent === RISE_EVENT)
total += startOfDay + minutesInDay / 1440 - lastTime;
return min(round(total * 1440), minutesInDay);
}
getMonthOfEvents(body, year, month, observer, zone, gregorianChange, targetAltitude) {
const monthsEvents = [];
const eAndSList = this.getEquinoxesAndSolsticesForOneYear(year, zone, gregorianChange);
const dateTime = new DateTime(null, zone || Timezone.UT_ZONE, gregorianChange);
for (const event of eAndSList) {
if (event.eventTime.wallTime.m === month) {
monthsEvents.push(event);
break;
}
}
const keyPhases = this.getLunarPhasesForMonth(year, month, zone, gregorianChange);
Array.prototype.push.apply(monthsEvents, keyPhases);
const firstDay = dateTime.getFirstDateInMonth(year, month);
const lastDay = dateTime.getLastDateInMonth(year, month);
const missing = dateTime.getMissingDateRange(year, month);
let doTwilight = false;
if (targetAltitude == null) {
targetAltitude = -REFRACTION_AT_HORIZON;
if (body === SUN || body === MOON)
targetAltitude -= AVG_SUN_MOON_RADIUS;
}
else
doTwilight = true;
for (let day = firstDay; day <= lastDay; ++day) {
if (missing && missing[0] <= day && day <= missing[1])
continue;
const risesAndSets = this.getRiseAndSetTimes(body, year, month, day, observer, zone, gregorianChange, 0, targetAltitude, doTwilight);
Array.prototype.push.apply(monthsEvents, risesAndSets);
const transits = this.getTransitTimes(body, year, month, day, observer, zone, gregorianChange);
Array.prototype.push.apply(monthsEvents, transits);
}
monthsEvents.sort((a, b) => {
return a.eventTime.utcTimeMillis - b.eventTime.utcTimeMillis;
});
return monthsEvents;
}
getRiseAndSetEvents(body, year, month, day, dayCount, observer, zone, gregorianChange, twilightAltitude) {
const results = [];
const calendar = new Calendar(gregorianChange);
let dayNum = 0;
const calculate = () => {
const startTick = Date.now();
while (dayNum < dayCount && Date.now() < startTick + 50) {
const ymd = calendar.addDaysToDate(dayNum, year, month, day);
const eventsForOneDay = [];
Array.prototype.push.apply(eventsForOneDay, this.getRiseAndSetTimes(body, ymd.y, ymd.m, ymd.d, observer, zone, gregorianChange, 0, null, false));
if (body === SUN && twilightAltitude != null)
Array.prototype.push.apply(eventsForOneDay, this.getRiseAndSetTimes(body, ymd.y, ymd.m, ymd.d, observer, zone, gregorianChange, 0, twilightAltitude, true));
Array.prototype.push.apply(eventsForOneDay, this.getTransitTimes(body, ymd.y, ymd.m, ymd.d, observer, zone, gregorianChange));
if (eventsForOneDay.length > 0) {
eventsForOneDay.sort((a, b) => {
return sign(a.ut - b.ut);
});
results.push(eventsForOneDay);
}
++dayNum;
}
};
return new Promise((resolve) => {
const loop = () => {
calculate();
if (dayNum === dayCount)
resolve(results);
else
setTimeout(loop);
};
setTimeout(loop);
});
}
getRiseAndSetEventsAsHtml(body, year, month, day, dayCount, observer, zone, gregorianChange, twilightAltitude, options) {
return this.getRiseAndSetEvents(body, year, month, day, dayCount, observer, zone, gregorianChange, twilightAltitude).then(daysOfEvents => {
const results = [];
const doTwilight = (body === SUN && twilightAltitude != null);
if (options && options.tableClass)
results.push(`<table class="${options.tableClass}">\n`);
else
results.push('<table>\n');
let headers;
if (options && options.headers)
headers = options.headers;
else if (doTwilight)
headers = ['Twilight\nBegins', '\nRise', '\nTransit', '\nSet', 'Twilight\nEnds'];
else
headers = ['Rise', 'Transit', 'Set'];
let extraColumn = false;
if (!doTwilight)
extraColumn = !!flatten(daysOfEvents).find(evt => evt.eventType === VISIBLE_ALL_DAY);
if (extraColumn && headers.length === 3)
headers.push('\u00A0');
results.push(' <tr>\n <th> </th>');
headers.forEach(header => results.push(`<th>${esc(header)}</th>`));
results.push('\n </tr>\n');
let lastMonth = -1;
let formatDate = EventFinder.formatEventDate;
if (options && options.formatDate)
formatDate = options.formatDate;
const formatDay = (event) => {
const d = event.eventTime.wallTime.d;
return (d < 10 ? '0' : '') + d;
};
if (options && options.formatDay)
formatDate = options.formatDay;
let formatTime = EventFinder.formatEventTime;
if (options && options.formatTime)
formatTime = options.formatTime;
let unseenAllDay = 'below horizon all day';
if (options && options.unseenAllDay)
unseenAllDay = options.unseenAllDay;
let visibleAllDay = 'above horizon all day';
if (options && options.visibleAllDay)
visibleAllDay = options.visibleAllDay;
daysOfEvents.forEach(events => {
let date;
const m = events[0].eventTime.wallTime.m;
if (m !== lastMonth) {
date = formatDate(events[0]);
lastMonth = m;
}
else
date = formatDay(events[0]);
results.push(' <tr>\n');
results.push(` <td>${date}</td>`);
const tableOffset = (doTwilight ? 0 : 1);
const cols = (doTwilight ? 5 : (extraColumn ? 4 : 3));
const output = [[], []];
events.forEach(event => {
let col;
let row;
let text;
// noinspection FallThroughInSwitchStatementJS
switch (event.eventType) {
case TWILIGHT_BEGINS:
col = 0;
break;
case UNSEEN_ALL_DAY:
text = unseenAllDay;
// eslint-disable-next-line no-fallthrough
case RISE_EVENT:
col = 1;
break;
case TRANSIT_EVENT:
col = 2;
break;
case VISIBLE_ALL_DAY:
text = visibleAllDay;
// eslint-disable-next-line no-fallthrough
case SET_EVENT:
col = 3;
break;
case TWILIGHT_ENDS:
col = 4;
break;
}
col -= tableOffset;
row = 0;
if (output[row][col])
++row;
if (!text)
text = formatTime(event);
output[row][col] = text;
});
if (output[1].length === 0)
output.length = 1;
let skip = 0;
output.forEach((row, rowIndex) => {
if (rowIndex > 0)
results.push('\n </tr>\n <td> </td>');
for (let i = 0; i < cols; ++i) {
if (skip) {
--skip;
continue;
}
const text = row[i];
if (!text)
results.push('<td> </td>');
else if (text === unseenAllDay || text === visibleAllDay) {
results.push(`<td colspan="2">${text}</td>`);
skip = 1;
}
else
results.push(`<td>${text}</td>`);
}
});
results.push('\n </tr>\n');
});
return results.join('');
});
}
getGalileanMoonEvents(startJdu, endJdu, includeGrsTransits, zone, gregorianChange) {
const results = [];
let t = floor(startJdu * 1440) / 1440;
const calculate = () => {
const startTick = Date.now();
do {
const mEvents = this.jupitersMoons.getMoonEventsForOneMinuteSpan(t, true, includeGrsTransits ? this.jupiterInfo : null);
if (mEvents.count > 0) {
// Round event time to nearest minute by adding half a minute.
const event = AstroEvent.fromJdu(GALILEAN_MOON_EVENT, mEvents.text, t + 1 / 2880, zone, gregorianChange, mEvents.searchΔT);
event.miscInfo = mEvents;
results.push(event);
}
t += mEvents.searchΔT / 1440;
} while (t < endJdu && Date.now() < startTick + 50);
};
return new Promise((resolve) => {
const loop = () => {
calculate();
if (t >= endJdu)
resolve(results);
else
setTimeout(loop);
};
setTimeout(loop);
});
}
getGalileanMoonEventsAsHtml(startJdu, endJdu, includeGrsTransits, zone, gregorianChange, options) {
return this.getGalileanMoonEvents(startJdu, endJdu, includeGrsTransits, zone, gregorianChange).then(events => {
const results = [];
let lastDay = -1;
if (options && options.tableClass)
results.push(`<table class="${options.tableClass}">\n`);
else
results.push('<table>\n');
let formatDateTime = EventFinder.formatEventDateTime;
if (options && options.formatDateTime)
formatDateTime = options.formatDateTime;
let formatTime = EventFinder.formatEventTime;
if (options && options.formatTime)
formatTime = options.formatTime;
events.forEach(event => {
let dateTime;
const wallTime = event.eventTime.wallTime;
if (wallTime.d !== lastDay) {
dateTime = formatDateTime(event);
lastDay = wallTime.d;
}
else
dateTime = formatTime(event);
results.push(' <tr>\n');
results.push(` <td>${dateTime}</td><td>${event.eventText}</td>\n`);
results.push(' </tr>\n');
});
results.push('</table>\n');
return results.join('');
});
}
resolveLocalCircumstances(result, eventType, originalTime, doPrevious, observer, zone, gregorianChange) {
const isSolar = (eventType === SOLAR_ECLIPSE_LOCAL);
const body = isSolar ? SUN : MOON;
const annularity = [0];
const penumbralMagnitude = [0];
const minMaxFinder = new MinMaxFinder((x) => {
return isSolar ?
this.ss.getLocalSolarEclipseTotality(utToTdt(x), observer, true, annularity) :
this.ss.getLunarEclipseTotality(utToTdt(x), true, penumbralMagnitude);
}, 1E-11, 50, result.ut - HALF_DAY, result.ut, result.ut + HALF_DAY);
const eventTime = minMaxFinder.getXAtMinMax();
if (!doPrevious && eventTime <= originalTime + MINUTE)
return null;
else if (doPrevious && eventTime >= originalTime - MINUTE)
return null;
else if (minMaxFinder.lastY > 0) {
const circumstances = { maxEclipse: min(minMaxFinder.lastY * 100, 100), maxTime: eventTime };
this.ss.getLocalSolarEclipseTotality(utToTdt(eventTime), observer, true, annularity);
circumstances.annular = (annularity[0] >= 1);
const firstContactFinder = new ZeroFinder((x) => {
return isSolar ?
this.ss.getLocalSolarEclipseTotality(utToTdt(x), observer, true) :
this.ss.getLunarEclipseTotality(utToTdt(x), true);
}, 1E-11, 50, result.ut - HALF_DAY, eventTime);
circumstances.firstContact = firstContactFinder.getXAtZero();
const lastContactFinder = new ZeroFinder((x) => {
return isSolar ?
this.ss.getLocalSolarEclipseTotality(utToTdt(x), observer, true) :
this.ss.getLunarEclipseTotality(utToTdt(x), true);
}, 1E-11, 50, eventTime, result.ut + HALF_DAY);
circumstances.lastContact = lastContactFinder.getXAtZero();
circumstances.duration = (circumstances.lastContact - circumstances.firstContact) * 86400;
if (minMaxFinder.lastY > 1 || annularity[0] > 1) {
const startFinder = new ZeroFinder((x) => {
const totality = isSolar ?
this.ss.getLocalSolarEclipseTotality(utToTdt(x), observer, true, annularity) :
this.ss.getLunarEclipseTotality(utToTdt(x), true);
if (circumstances.annular)
return annularity[0] - 1;
else
return totality - 1;
}, 1E-11, 50, circumstances.firstContact, eventTime);
circumstances.peakStarts = startFinder.getXAtZero();
const endFinder = new ZeroFinder((x) => {
const totality = isSolar ?
this.ss.getLocalSolarEclipseTotality(utToTdt(x), observer, true, annularity) :
this.ss.getLunarEclipseTotality(utToTdt(x), true);
if (isSolar && circumstances.annular)
return annularity[0] - 1;
else
return totality - 1;
}, 1E-11, 50, eventTime, circumstances.lastContact);
circumstances.peakEnds = endFinder.getXAtZero();
circumstances.peakDuration = (circumstances.peakEnds - circumstances.peakStarts) * 86400;
}
else
circumstances.peakDuration = 0;
if (!isSolar) {
if (penumbralMagnitude[0] > 0) {
const firstContactFinder = new ZeroFinder((x) => {
// eslint-disable-next-line no-sequences
return this.ss.getLunarEclipseTotality(utToTdt(x), true, penumbralMagnitude), penumbralMagnitude[0];
}, 1E-11, 50, result.ut - HALF_DAY, eventTime);
circumstances.penumbralFirstContact = firstContactFinder.getXAtZero();
const lastContactFinder = new ZeroFinder((x) => {
// eslint-disable-next-line no-sequences
return this.ss.getLunarEclipseTotality(utToTdt(x), true, penumbralMagnitude), penumbralMagnitude[0];
}, 1E-11, 50, eventTime, result.ut + HALF_DAY);
circumstances.penumbralLastContact = lastContactFinder.getXAtZero();
circumstances.penumbralDuration = (circumstances.penumbralLastContact - circumstances.penumbralFirstContact) * 86400;
}
else
circumstances.penumbralDuration = 0;
}
if (this.ss.getHorizontalPosition(body, circumstances.peakStarts, observer).altitude.degrees > 0 ||
this.ss.getHorizontalPosition(body, circumstances.peakEnds, observer).altitude.degrees > 0 ||
this.ss.getHorizontalPosition(body, circumstances.maxTime, observer).altitude.degrees > 0) {
const event = AstroEvent.fromJdu(isSolar ? SOLAR_ECLIPSE_LOCAL : LUNAR_ECLIPSE_LOCAL, '', eventTime, zone, gregorianChange, minMaxFinder.lastY);
event.miscInfo = new CircumstancesOfEclipse(circumstances);
return event;
}
}
return null;
}
async findEventAsync(planet, eventType, originalTime, observer, zone, gregorianChange, doPrevious = false, argument, maxTries = Number.MAX_SAFE_INTEGER) {
let type = eventType;
let result;
let testTime = originalTime;
let tries = 0;
if (eventType === SOLAR_ECLIPSE_LOCAL)
type = SOLAR_ECLIPSE;
else if (eventType === LUNAR_ECLIPSE_LOCAL)
type = LUNAR_ECLIPSE;
while (tries <= maxTries) {
result = await this.findEventAsyncImpl(planet, type, testTime, observer, zone, gregorianChange, doPrevious, argument, maxTries);
if (!result || type === eventType)
break;
else if (eventType === SOLAR_ECLIPSE_LOCAL || eventType === LUNAR_ECLIPSE_LOCAL) {
result = this.resolveLocalCircumstances(result, eventType, originalTime, doPrevious, observer, zone, gregorianChange);
if (result)
break;
else
testTime += doPrevious ? -2 : 2;
++tries;
await new Promise(resolve => setTimeout(resolve));
}
}
return result;
}
async findEventAsyncImpl(planet, eventType, originalTime, observer, zone, gregorianChange, doPrevious = false, argument, maxTries = Number.MAX_SAFE_INTEGER) {
if (!zone)
zone = Timezone.UT_ZONE;
const δ = (doPrevious ? -1 : 1);
originalTime += δ * HALF_MINUTE; // Bias time by a half minute towards the event seek direction.
const dateTime = new DateTime(DateTime.millisFromJulianDay(originalTime), zone, gregorianChange);
const ymd = dateTime.wallTime;
const testTime = [originalTime];
let event;
let tries = 0;
let processTime = processMillis(), now;
while (tries <= maxTries) {
event = await new Promise(resolve => {
resolve(this.eventSearch(planet, eventType, originalTime, testTime, observer, zone, gregorianChange, doPrevious, argument, tries, dateTime, ymd));
});
if (event || event === null)
return event;
++tries;
now = processMillis();
if (now > processTime + 100) {
processTime = now;
await new Promise(resolve => setTimeout(resolve, 10));
}
}
}
findEvent(planet, eventType, originalTime, observer, zone, gregorianChange, doPrevious = false, argument, maxTries = Number.MAX_SAFE_INTEGER) {
if (eventType === LUNAR_ECLIPSE_LOCAL && maxTries > 2)
throw new Error('LUNAR_ECLIPSE_LOCAL requires findEventAsync() or maxTries <= 2');
else if (eventType === SOLAR_ECLIPSE_LOCAL && maxTries > 2)
throw new Error('SOLAR_ECLIPSE_LOCAL requires findEventAsync() or maxTries <= 2');
else if (!zone)
zone = Timezone.UT_ZONE;
const δ = (doPrevious ? -1 : 1);
let type = eventType;
if (eventType === SOLAR_ECLIPSE_LOCAL)
type = SOLAR_ECLIPSE;
else if (eventType === LUNAR_ECLIPSE_LOCAL)
type = LUNAR_ECLIPSE;
originalTime += δ * HALF_MINUTE; // Bias time by a half minute towards the event seek direction.
const dateTime = new DateTime(DateTime.millisFromJulianDay(originalTime), zone, gregorianChange);
const ymd = dateTime.wallTime;
const testTime = [originalTime];
let event;
let tries = 0;
while (tries <= maxTries) {
event = this.eventSearch(planet, type, originalTime, testTime, observer, zone, gregorianChange, doPrevious, argument, tries, dateTime, ymd);
if (event || event === null)
break;
++tries;
}
if (event && (eventType === SOLAR_ECLIPSE_LOCAL || eventType === LUNAR_ECLIPSE_LOCAL))
event = this.resolveLocalCircumstances(event, eventType, originalTime, doPrevious, observer, zone, gregorianChange);
return event;
}
eventSearch(planet, eventType, originalTime, testTime, observer, zone, gregorianChange, doPrevious, argument, tries, dateTime, ymd) {
let eventPeriod = 0;
let eventTime;
let events;
let event;
let a, b;
let minEventGap = 5, eventGap;
let minuteRounding = true;
const δ = (doPrevious ? -1 : 1);
// argument must be boolean true, not just truthy
if (argument === true && [LUNAR_ECLIPSE, SOLAR_ECLIPSE].includes(eventType))
minuteRounding = false;
switch (eventType) {
case RISE_EVENT:
case SET_EVENT:
case SET_EVENT_MINUS_1_MIN:
case TRANSIT_EVENT:
case TWILIGHT_BEGINS:
case TWILIGHT_ENDS:
if (tries > 0)
Object.assign(ymd, dateTime.addDaysToDate(δ, ymd));
let minsBefore = 0;
let targetAlt;
if (eventType === TWILIGHT_BEGINS || eventType === TWILIGHT_ENDS) {
if (!isNumber(argument))
targetAlt = NAUTICAL_TWILIGHT;
else if (argument < 0)
targetAlt = argument;
else
minsBefore = (eventType === TWILIGHT_ENDS ? -argument : argument);
events = this.getRiseAndSetTimes(SUN, ymd.y, ymd.m, ymd.d, observer, zone, gregorianChange, minsBefore, targetAlt, true);
}
else if (eventType === TRANSIT_EVENT)
events = this.getTransitTimes(planet, ymd.y, ymd.m,