UNPKG

@tubular/astronomy

Version:

Astronomical calculations for planetary positions, moon phases, eclipses, rise, transit, and set times, and more.

993 lines 62.2 kB
// 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>&nbsp;</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>&nbsp;</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('&nbsp;'); results.push('</td>'); } while (event.eventType - PHASE_EVENT_BASE + 1 > col) { results.push('<td>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</td>'); for (let i = 0; i < cols; ++i) { if (skip) { --skip; continue; } const text = row[i]; if (!text) results.push('<td>&nbsp;</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,