UNPKG

@marchingy/lunar

Version:

Chinese calendar with the 24 solar terms.

350 lines (347 loc) 12.3 kB
import { calcDiffOfSunAndMoon, countSolarTerms, getTermOnDay, getTermsOnYear } from './solar-terms'; import { toPrecision } from './tool'; export const TIME_ZONE_OFFSET = new Date().getTimezoneOffset(); export const CHINESE_OFFSET = -480; const ZODIACS = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']; const CELESTIAL_STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']; const TERRESTRIAL_BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']; const CAPITALIZE_NUMBER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; const CAPITALIZE_TEN_NUMBER = ['初', '十', '廿', '卅']; const DAY_TIME = 24 * 60 * 60 * 1000; const YEAR_ORIGIN = new Date(1984, 1, 2); const MONTH_ORIGIN = new Date(2013, 11, 7); const DAY_ORIGIN = new Date(1949, 9, 1, 0); /* const PRC_TIMEZON_CHANGED_HISTORY = new Map<number, DaylightSavingTimeChange>([ [1991, { divider: new Date(1991, 8, 15, 2), offset: 0 }], [1987, { daylightSavingTime: { start: new Date(1919, 3, 12, 2), end: new Date(1991, 9, 13, 2) }, divider: null, offset: 1 }], [1919, { daylightSavingTime: { start: new Date(1919, 3, 13), end: new Date(1991, 9, 1) }, divider: null, offset: 1 }] ]); interface DaylightSavingTimeChange { daylightSavingTime?: { start: Date; end: Date; }; divider: Date | null; offset: number; } */ export function countDaysOnYear(year) { return Math.ceil((Date.UTC(year, 11, 31, 23, 59, 59, 999) - Date.UTC(year, 0, 1)) / DAY_TIME); } export function isNewMoon(date) { const yesterdday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1, 23, 59, 59); const tomorrow = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); const startTime = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0); const endTime = new Date(tomorrow.getTime() - 1000); const startDiff = toPrecision(calcDiffOfSunAndMoon(startTime), 4); const endDiff = toPrecision(calcDiffOfSunAndMoon(endTime), 4); // If the sun ecliptic longitude overlay with moon ecliptic longitude almost at zero hour, // need to detect yesterday and tomorrow whether are new moon. if ((startDiff < 0.5 && toPrecision(calcDiffOfSunAndMoon(yesterdday), 4) < startDiff) || (endDiff < 0.5 && toPrecision(calcDiffOfSunAndMoon(tomorrow), 4) < endDiff)) { return false; } const stime = yesterdday.getTime(); let oldSum; let sum; let result = false; let i = 0; do { if (i > 24) { console.warn(`Potential infinite loop! Start time: ${startTime.toLocaleString()}, current time: ${endTime.toLocaleString()}. => ${endTime.getTime() >= stime}`); break; } oldSum = sum; sum = calcDiffOfSunAndMoon(endTime); if (!isNaN(oldSum) && sum > oldSum) { break; } if (sum < 0.5) { result = true; break; } if (endTime.getHours() === 0 && endTime.getMinutes() !== 0) { endTime.setHours(endTime.getHours(), 0, 0); } else { endTime.setTime(endTime.getTime() - 3600000); endTime.setMinutes(59, 59); } /* endTime.setTime(endTime.getTime() - (sum / 2 / (360 / 30)) * DAY_TIME); if (endTime.getTime() <= stime) { break; } */ i++; } while (endTime.getTime() > stime); return result; } export function countNewMoons(fromDate, toDate) { const newMoons = []; let startDate; let endDate; if (fromDate.getTime() <= toDate.getTime()) { startDate = new Date(fromDate.getFullYear(), fromDate.getMonth(), fromDate.getDate()); endDate = new Date(toDate.getFullYear(), toDate.getMonth(), toDate.getDate()); } else { startDate = new Date(toDate.getFullYear(), toDate.getMonth(), toDate.getDate()); endDate = new Date(fromDate.getFullYear(), fromDate.getMonth(), fromDate.getDate()); } do { if (isNewMoon(startDate)) { newMoons.push(new Date(startDate)); startDate.setDate(startDate.getDate() + 27); continue; } startDate.setDate(startDate.getDate() + 1); } while (startDate.getTime() <= endDate.getTime()); return newMoons; } export function getWinterSolstice(year) { const lastTerms = getTermsOnYear(year); const winterSolstice = lastTerms.find((item) => item.longitude === 270).date; return winterSolstice; } export function getWinterSolsticeRange(date) { const year = date.getFullYear(); let newMoons; let curr11thNewMoon = getWinterSolstice(year); let winterSolstice; if (!isNewMoon(curr11thNewMoon)) { const prevNewMoons = countNewMoons(new Date(curr11thNewMoon.getFullYear(), curr11thNewMoon.getMonth(), curr11thNewMoon.getDate() - 30), curr11thNewMoon); if (prevNewMoons.length < 1) { throw new Error(`The first eleventh month is incorrect. ${prevNewMoons}`); } curr11thNewMoon = prevNewMoons.pop(); } if (date.getTime() >= curr11thNewMoon.getTime()) { winterSolstice = getWinterSolstice(year + 1); newMoons = countNewMoons(curr11thNewMoon, winterSolstice); } else { winterSolstice = getWinterSolstice(year - 1); newMoons = countNewMoons(winterSolstice, curr11thNewMoon); if (!isNewMoon(winterSolstice)) { const prevNewMoons = countNewMoons(new Date(winterSolstice.getFullYear(), winterSolstice.getMonth(), winterSolstice.getDate() - 30), winterSolstice); if (prevNewMoons.length < 1) { throw new Error(`The last eleventh month is incorrect. ${prevNewMoons}`); } newMoons.unshift(prevNewMoons.pop()); } } newMoons.pop(); // Remove the latest eleventh month. return newMoons; } export function isMissingMidTermMonth(date) { let nextDay; let isMissing = true; let i = 0; if (isMissing) { nextDay = new Date(date); do { i++; if (i > 1 && isNewMoon(nextDay)) { break; } if (getTermOnDay(nextDay)?.isMidTerm()) { isMissing = false; break; } nextDay.setDate(nextDay.getDate() + 1); } while (i <= 30); } if (isMissing) { nextDay = new Date(date); do { i++; if (getTermOnDay(nextDay)?.isMidTerm()) { isMissing = false; break; } if (isNewMoon(nextDay)) { break; } nextDay.setDate(nextDay.getDate() - 1); } while (i <= 30); } return isMissing; } export function isLeapMonth(date) { const currentTime = date.getTime(); const newMoons = getWinterSolsticeRange(date); const countNewMoon = newMoons.length; let isLeap = false; if (countNewMoon === 13) { let leapIndex = newMoons.findIndex((item) => isMissingMidTermMonth(item)); if (leapIndex < 0) { leapIndex = countNewMoon - 1; } const leapMonthStart = newMoons[leapIndex]; let leapMonthEnd = newMoons[leapIndex + 1]; if (!leapMonthEnd) { const nextNewMoons = countNewMoons(leapMonthStart, new Date(leapMonthStart.getFullYear(), leapMonthStart.getMonth(), leapMonthStart.getDate() + 31)); leapMonthEnd = nextNewMoons[1]; } isLeap = currentTime >= leapMonthStart.getTime() && currentTime < leapMonthEnd.getTime(); } return isLeap; } class LunarDateProperty extends Number { constructor(value, nativeDate) { super(Math.floor(value)); this.nativeDate = nativeDate; this.offset = this.calcDiff(value); } sexagesimal() { const indexStem = this.calcCelestialStem(); const indexBranch = this.calcTerrestrialBranch(); return `${CELESTIAL_STEMS[indexStem]}${TERRESTRIAL_BRANCHES[indexBranch]}`; } calcCelestialStem() { return (this.offset + 10) % 10; } calcTerrestrialBranch() { return (this.offset + 12) % 12; } } class LunarYear extends LunarDateProperty { constructor() { super(...arguments); this.toString = () => { return `${this.sexagesimal()}${this.zodiac()}年`; }; } zodiac() { const indexBranch = this.calcTerrestrialBranch(); return `${ZODIACS[indexBranch]}`; } calcDiff() { const year = this.nativeDate.getFullYear(); const winterSolstice = getWinterSolstice(year - 1); const newMoons = countNewMoons(winterSolstice, this.nativeDate).filter((item) => !isLeapMonth(item)); const firstNewMoon = newMoons[1 + (isNewMoon(winterSolstice) ? 1 : 0)]; let offset = year - YEAR_ORIGIN.getFullYear(); if (!firstNewMoon) { offset -= 1; } return offset; } } class LunarMonth extends LunarDateProperty { constructor() { super(...arguments); this.toString = () => { return `${this.sexagesimal()}${this.capital()}月`; }; } isLeap() { return isLeapMonth(this.nativeDate); } capital() { const value = this.valueOf(); return value > 10 ? `${CAPITALIZE_TEN_NUMBER[Math.floor(value / 10)]}${CAPITALIZE_NUMBER[value % 10 - 1]}` : `${CAPITALIZE_NUMBER[value - 1]}`; } calcDiff(value) { const terms = countSolarTerms(MONTH_ORIGIN, this.nativeDate); const nonMidTerms = terms.filter((item) => { return !item.isMidTerm(); }); const offset = nonMidTerms.length - 1; return offset; } } class LunarDay extends LunarDateProperty { constructor() { super(...arguments); this.toString = () => { return `${this.sexagesimal()}${this.capital()}`; }; } capital() { const value = this.valueOf(); const unit = value % 10; return `${CAPITALIZE_TEN_NUMBER[Math.floor(value / 10)]}${unit === 0 ? CAPITALIZE_NUMBER[9] : CAPITALIZE_NUMBER[unit - 1]}`; } calcDiff(value) { return Math.round((this.nativeDate.getTime() - DAY_ORIGIN.getTime()) / DAY_TIME); } } /** * @description 8h = 28800000ms, minus 8 hours to get china time zone date. */ export class ChineseDate extends Date { /** * * @returns A new instance of LunarYear. */ getLunarYear() { return new LunarYear(this.getFullYear(), this); } /** * @description A lunar month is 11 if winter solstice is in this month. * @returns A new instance of LunarMonth. */ getLunarMonth() { const currentTime = this.getTime(); const newMoons = getWinterSolsticeRange(this); const countNewMoon = newMoons.length; let month; month = newMoons.findIndex((item) => item.getTime() > currentTime); if (month < 0) { month = countNewMoon - 1; } else { month--; } if (countNewMoon === 13) { const leap = newMoons.find((item) => isMissingMidTermMonth(item)) || newMoons[countNewMoon - 1]; if (currentTime >= leap.getTime()) { month--; } } month = month > 1 ? ((11 + month) % 12) : 11 + month; return new LunarMonth(month, this); } /** * * @returns A new instance of LunarDay. */ getLunarDay() { const testDate = new Date(this); let i = 1; while (!isNewMoon(testDate)) { i++; if (i > 30) { throw new Error(`Potential infinite loop! Invalid lunar day ${i}.`); } testDate.setDate(testDate.getDate() - 1); } ; return new LunarDay(i, this); } toChineseString() { return super.toLocaleString('zh-CN', { timeZone: 'PRC' }); } toLunarString() { return `农历${this.getLunarYear().sexagesimal()}${this.getLunarMonth().capital()}${this.getLunarDay().sexagesimal()}日`; } }