@marchingy/lunar
Version:
Chinese calendar with the 24 solar terms.
350 lines (347 loc) • 12.3 kB
JavaScript
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()}日`;
}
}