Files
AriWeb-Suite/modules/webmaker/lib/lunar.js
T

263 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ============================================================================
// 만세력 계산 (v1.4.18, 요구 1 — 일정관리 모듈의 음력·간지 표시)
//
// 목적: 양력 날짜 ↔ 음력 날짜 변환과 사주식 간지(연주·월주·일주)를 외부 자료표 없이 계산한다.
// 일정관리 캘린더에 "음 9.3 · 병오년 무술월 신유일" 같은 표시를 넣고, 음력 기준 일정(음력 생일·제사·명절)을
// 해마다 양력 날짜로 바꿔 보여 주기 위해 쓴다.
//
// 방식 (한국천문연구원 만세력과 같은 규칙, 한국 표준시 KST 기준):
// - 음력 달은 합삭(朔, 새달) 순간이 든 날(KST)에 시작한다. 합삭 시각은 Meeus 「천문 알고리즘」 49장(달의 위상) 급수로 구한다.
// - 달의 번호는 중기(中氣: 우수·춘분·곡우·소만·하지·대서·처서·추분·상강·소설·동지·대한 = 태양 황경 30° 배수)로 정한다.
// 동지가 든 달이 11월이고, 동지와 다음 동지 사이(한 "세(歲)")에 달이 13개 있으면 그 사이에서 중기가 없는 첫 달이 윤달이다.
// - 태양 황경은 VSOP87 지구 급수(Meeus 부록 III 축약, 정확도 약 1″)로 구하고 장동·광행차를 더한 시황경을 쓴다.
// 절기(24절기) 시각은 황경이 15° 배수가 되는 순간을 반복법으로 찾는다. 역표시(TT)와 세계시(UT)의 차 ΔT 는 Espenak-Meeus 다항식.
// - 간지: 일주(日柱)는 60일 주기(율리우스일 기준, 2000-01-01 = 무오일), 연주(年柱)는 입춘(황경 315°) 순간에 바뀌고,
// 월주(月柱)는 절기(節氣: 입춘·경칩·청명·입하·망종·소서·입추·백로·한로·입동·대설·소한 = 황경 15°+30°k)에 바뀌며
// 월간(月干)은 오호둔(五虎遁: 갑·기년 → 병인월, 을·경년 → 무인월, 병·신년 → 경인월, 정·임년 → 임인월, 무·계년 → 갑인월)으로 정한다.
// 캘린더는 날짜 단위로 보이므로 절입(節入) 당일은 새 달의 간지로 표시한다(만세력 책과 같은 관행).
//
// 정확도: 합삭·절기 시각 오차는 수십 초 이내라 KASI 공식 음력과 거의 같다. 다만 합삭이나 절기가 자정(KST) 수십 초 안쪽에 드는
// 극히 드문 경우에는 하루 차이가 날 수 있다(1900~2100년 사이 몇 번 수준). 유효 범위는 1900~2100년이다.
//
// 공개 함수
// solarToLunar(y, m, d) → { year, month, day, leap, monthDays } (음력)
// lunarToSolar(ly, lm, ld, leap, clamp) → { y, m, d } (양력) 또는 null (그 날이 없을 때; clamp=true 면 그 달 마지막 날로)
// ganji(y, m, d) → { year, month, day } 각각 { idx, hangul, hanja } + text
// dayInfo(y, m, d) → 위 둘 + 그날 절기 이름 (캘린더 한 칸에 보일 정보)
// solarTerms(year) → 그 해 24절기 [{ name, lon, at(ISO KST), date }]
// lunarYearMonths(ly) → 음력 한 해의 달 목록 [{ month, leap, days, start:{y,m,d} }]
// ============================================================================
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const J2000 = 2451545.0; // 2000-01-01 12:00 TT 의 율리우스일
const KST_OFFSET = 9 / 24; // 한국 표준시 = UT + 9시간 (율리우스일 단위)
const RANGE = [1900, 2100]; // 계산을 허용하는 양력 연도 범위
// ---------- 날짜 ↔ 율리우스일 (그레고리력, 정오 기준 JDN 은 정수) ----------
/** 양력 날짜 → 율리우스일 번호(정수, 그날 정오). 역산은 dateFromJdn. */
function jdn(y, m, d) {
const a = Math.floor((14 - m) / 12); const yy = y + 4800 - a; const mm = m + 12 * a - 3;
return d + Math.floor((153 * mm + 2) / 5) + 365 * yy + Math.floor(yy / 4) - Math.floor(yy / 100) + Math.floor(yy / 400) - 32045;
}
/** 율리우스일 번호(정수) → 양력 날짜 */
function dateFromJdn(j) {
const a = j + 32044; const b = Math.floor((4 * a + 3) / 146097); const c = a - Math.floor(146097 * b / 4);
const d = Math.floor((4 * c + 3) / 1461); const e = c - Math.floor(1461 * d / 4); const m = Math.floor((5 * e + 2) / 153);
return { y: 100 * b + d - 4800 + Math.floor(m / 10), m: m + 3 - 12 * Math.floor(m / 10), d: e - Math.floor((153 * m + 2) / 5) + 1 };
}
/** 세계시(UT) 율리우스일 → 그 순간이 속한 KST 날짜의 JDN (자정 기준: JD 는 정오에 바뀌므로 0.5 를 더해 내림) */
const kstDayOf = (jdUt) => Math.floor(jdUt + KST_OFFSET + 0.5);
/** 율리우스일(UT) → ISO 문자열(KST, 분 단위) — 절기 시각 표시용 */
function isoKst(jdUt) {
const local = jdUt + KST_OFFSET; const j = Math.floor(local + 0.5); const frac = local + 0.5 - j; const { y, m, d } = dateFromJdn(j);
const mins = Math.round(frac * 1440) % 1440; const hh = Math.floor(mins / 60); const mi = mins % 60; const p = (n) => String(n).padStart(2, '0');
return `${y}-${p(m)}-${p(d)}T${p(hh)}:${p(mi)}+09:00`;
}
// ---------- ΔT (역표시 − 세계시, 초) : Espenak & Meeus 다항식 ----------
/** 연도(소수 가능) → ΔT 초. 천문 급수는 역표시(TT) 기준이라 UT 로 바꿀 때 뺀다. 몇 초 오차는 날짜 판정에 영향 없다. */
function deltaT(year) {
const y = year;
if (y < 1920) { const t = y - 1900; return -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4; }
if (y < 1941) { const t = y - 1920; return 21.20 + 0.84493 * t - 0.076100 * t * t + 0.0020936 * t ** 3; }
if (y < 1961) { const t = y - 1950; return 29.07 + 0.407 * t - t * t / 233 + t ** 3 / 2547; }
if (y < 1986) { const t = y - 1975; return 45.45 + 1.067 * t - t * t / 260 - t ** 3 / 718; }
if (y < 2005) { const t = y - 2000; return 63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t ** 3 + 0.000651814 * t ** 4 + 0.00002373599 * t ** 5; }
if (y < 2050) { const t = y - 2000; return 62.92 + 0.32217 * t + 0.005589 * t * t; }
if (y < 2150) { return -20 + 32 * ((y - 1820) / 100) ** 2 - 0.5628 * (2150 - y); }
const u = (y - 1820) / 100; return -20 + 32 * u * u;
}
/** 역표시 율리우스일(JDE) → 세계시 율리우스일 */
const jdeToUt = (jde) => jde - deltaT(2000 + (jde - J2000) / 365.25) / 86400;
// ---------- 태양 시황경 (VSOP87 지구 급수 축약, Meeus 부록 III) ----------
// 각 항 [A, B, C] : A·cos(B + C·τ), τ = J2000 기준 율리우스 천년. 단위 1e-8 라디안.
const L0 = [[175347046, 0, 0], [3341656, 4.6692568, 6283.07585], [34894, 4.6261, 12566.1517], [3497, 2.7441, 5753.3849], [3418, 2.8289, 3.5231], [3136, 3.6277, 77713.7715], [2676, 4.4181, 7860.4194], [2343, 6.1352, 3930.2097], [1324, 0.7425, 11506.7698], [1273, 2.0371, 529.691], [1199, 1.1096, 1577.3435], [990, 5.233, 5884.927], [902, 2.045, 26.298], [857, 3.508, 398.149], [780, 1.179, 5223.694], [753, 2.533, 5507.553], [505, 4.583, 18849.228], [492, 4.205, 775.523], [357, 2.92, 0.067], [317, 5.849, 11790.629], [284, 1.899, 796.298], [271, 0.315, 10977.079], [243, 0.345, 5486.778], [206, 4.806, 2544.314], [205, 1.869, 5573.143], [202, 2.458, 6069.777], [156, 0.833, 213.299], [132, 3.411, 2942.463], [126, 1.083, 20.775], [115, 0.645, 0.98], [103, 0.636, 4694.003], [102, 0.976, 15720.839], [102, 4.267, 7.114], [99, 6.21, 2146.17], [98, 0.68, 155.42], [86, 5.98, 161000.69], [85, 1.3, 6275.96], [85, 3.67, 71430.7], [80, 1.81, 17260.15], [79, 3.04, 12036.46], [75, 1.76, 5088.63], [74, 3.5, 3154.69], [74, 4.68, 801.82], [70, 0.83, 9437.76], [62, 3.98, 8827.39], [61, 1.82, 7084.9], [57, 2.78, 6286.6], [56, 4.39, 14143.5], [56, 3.47, 6279.55], [52, 0.19, 12139.55], [52, 1.33, 1748.02], [51, 0.28, 5856.48], [49, 0.49, 1194.45], [41, 5.37, 8429.24], [41, 2.4, 19651.05], [39, 6.17, 10447.39], [37, 6.04, 10213.29], [37, 2.57, 1059.38], [36, 1.71, 2352.87], [36, 1.78, 6812.77], [33, 0.59, 17789.85], [30, 0.44, 83996.85], [30, 2.74, 1349.87], [25, 3.16, 4690.48]];
const L1 = [[628331966747, 0, 0], [206059, 2.678235, 6283.07585], [4303, 2.6351, 12566.1517], [425, 1.59, 3.523], [119, 5.796, 26.298], [109, 2.966, 1577.344], [93, 2.59, 18849.23], [72, 1.14, 529.69], [68, 1.87, 398.15], [67, 4.41, 5507.55], [59, 2.89, 5223.69], [56, 2.17, 155.42], [45, 0.4, 796.3], [36, 0.47, 775.52], [29, 2.65, 7.11], [21, 5.34, 0.98], [19, 1.85, 5486.78], [19, 4.97, 213.3], [17, 2.99, 6275.96], [16, 0.03, 2544.31], [16, 1.43, 2146.17], [15, 1.21, 10977.08], [12, 2.83, 1748.02], [12, 3.26, 5088.63], [12, 5.27, 1194.45], [12, 2.08, 4694], [11, 0.77, 553.57], [10, 1.3, 6286.6], [10, 4.24, 1349.87], [9, 2.7, 242.73], [9, 5.64, 951.72], [8, 5.3, 2352.87], [6, 2.65, 9437.76], [6, 4.67, 4690.48]];
const L2 = [[52919, 0, 0], [8720, 1.0721, 6283.0758], [309, 0.867, 12566.152], [27, 0.05, 3.52], [16, 5.19, 26.3], [16, 3.68, 155.42], [10, 0.76, 18849.23], [9, 2.06, 77713.77], [7, 0.83, 775.52], [5, 4.66, 1577.34], [4, 1.03, 7.11], [4, 3.44, 5573.14], [3, 5.14, 796.3], [3, 6.05, 5507.55], [3, 1.19, 242.73], [3, 6.12, 529.69], [3, 0.31, 398.15], [3, 2.28, 553.57], [2, 4.38, 5223.69], [2, 3.75, 0.98]];
const L3 = [[289, 5.844, 6283.076], [35, 0, 0], [17, 5.49, 12566.15], [3, 5.2, 155.42], [1, 4.72, 3.52], [1, 5.3, 18849.23], [1, 5.97, 242.73]];
const L4 = [[114, 3.142, 0], [8, 4.13, 6283.08], [1, 3.84, 12566.15]];
const L5 = [[1, 3.14, 0]];
const series = (terms, tau) => terms.reduce((s, [a, b, c]) => s + a * Math.cos(b + c * tau), 0);
const norm360 = (x) => ((x % 360) + 360) % 360;
/**
* 태양의 시황경(겉보기 황경, 도). 지구 일심 황경 + 180°, FK5 보정, 장동(Δψ), 광행차를 더한 값.
* @param {number} jde 역표시 율리우스일
*/
function sunLongitude(jde) {
const tau = (jde - J2000) / 365250; const T = tau * 10;
const L = (series(L0, tau) + series(L1, tau) * tau + series(L2, tau) * tau ** 2 + series(L3, tau) * tau ** 3 + series(L4, tau) * tau ** 4 + series(L5, tau) * tau ** 5) * 1e-8;
let lon = norm360(L * R2D + 180);
lon -= 0.09033 / 3600; // VSOP 동역학 좌표계 → FK5 보정
// 장동(황경 성분): Meeus 22장 축약식 (0.5″ 이내)
const om = (125.04452 - 1934.136261 * T) * D2R; const ls = (280.4665 + 36000.7698 * T) * D2R; const lm = (218.3165 + 481267.8813 * T) * D2R;
const dpsi = (-17.2 * Math.sin(om) - 1.32 * Math.sin(2 * ls) - 0.23 * Math.sin(2 * lm) + 0.21 * Math.sin(2 * om)) / 3600;
// 광행차 −20.4898″/R : 지구-태양 거리 R 은 Meeus 25장의 간이식(오차 0.01″ 이하)
const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R; const e = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T;
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M) + (0.019993 - 0.000101 * T) * Math.sin(2 * M) + 0.000289 * Math.sin(3 * M);
const R = 1.000001018 * (1 - e * e) / (1 + e * Math.cos(M + C * D2R));
return norm360(lon + dpsi - 20.4898 / R / 3600);
}
/** 두 각의 차를 −180~180 으로 */
const angDiff = (a, b) => ((a - b + 540) % 360) - 180;
/**
* 해당 연도에서 태양 황경이 lon(도)이 되는 순간(JDE). 평균 공전 속도로 초기값을 잡고 뉴턴법으로 수렴시킨다.
* lon 285°(소한, 1월 초)부터 270°(동지, 12월 하순)까지를 "그 해의 절기" 로 본다.
*/
function termJde(year, lon) {
const offset = norm360(lon - 285); // 소한을 0 으로 둔 황경 간격
let jde = jdn(year, 1, 5) + 0.5 + offset / 360 * 365.2422;
for (let i = 0; i < 10; i++) { const diff = angDiff(lon, sunLongitude(jde)); if (Math.abs(diff) < 1e-7) break; jde += diff / 360 * 365.2422; }
return jde;
}
// ---------- 합삭(새달) 시각 : Meeus 49장 ----------
/**
* k 번째 합삭(0 = 2000-01-06)의 역표시 율리우스일. 평균 합삭에 달·태양 궤도 보정과 행성 섭동 보정을 더한다.
* @param {number} k 정수
*/
function newMoonJde(k) {
const T = k / 1236.85; const T2 = T * T; const T3 = T2 * T; const T4 = T3 * T;
let jde = 2451550.09766 + 29.530588861 * k + 0.00015437 * T2 - 0.00000015 * T3 + 0.00000000073 * T4;
const E = 1 - 0.002516 * T - 0.0000074 * T2;
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T2 - 0.00000011 * T3) * D2R; // 태양 평균 근점이각
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T2 + 0.00001238 * T3 - 0.000000058 * T4) * D2R; // 달 평균 근점이각
const F = (160.7108 + 390.67050284 * k - 0.0016118 * T2 - 0.00000227 * T3 + 0.000000011 * T4) * D2R; // 달 위도 인수
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3) * D2R; // 승교점 황경
const s = Math.sin;
jde += -0.4072 * s(Mp) + 0.17241 * E * s(M) + 0.01608 * s(2 * Mp) + 0.01039 * s(2 * F) + 0.00739 * E * s(Mp - M) - 0.00514 * E * s(Mp + M) + 0.00208 * E * E * s(2 * M)
- 0.00111 * s(Mp - 2 * F) - 0.00057 * s(Mp + 2 * F) + 0.00056 * E * s(2 * Mp + M) - 0.00042 * s(3 * Mp) + 0.00042 * E * s(M + 2 * F) + 0.00038 * E * s(M - 2 * F)
- 0.00024 * E * s(2 * Mp - M) - 0.00017 * s(Om) - 0.00007 * s(Mp + 2 * M) + 0.00004 * s(2 * Mp - 2 * F) + 0.00004 * s(3 * M) + 0.00003 * s(Mp + M - 2 * F)
+ 0.00003 * s(2 * Mp + 2 * F) - 0.00003 * s(Mp + M + 2 * F) + 0.00003 * s(Mp - M + 2 * F) - 0.00002 * s(Mp - M - 2 * F) - 0.00002 * s(3 * Mp + M) + 0.00002 * s(4 * Mp);
// 행성 섭동 보정 (14개 인수)
const A = [[299.77, 0.107408, -0.009173], [251.88, 0.016321, 0], [251.83, 26.651886, 0], [349.42, 36.412478, 0], [84.66, 18.206239, 0], [141.74, 53.303771, 0], [207.14, 2.453732, 0], [154.84, 7.30686, 0], [34.52, 27.261239, 0], [207.19, 0.121824, 0], [291.34, 1.844379, 0], [161.72, 24.198154, 0], [239.56, 25.513099, 0], [331.55, 3.592518, 0]];
const coef = [0.000325, 0.000165, 0.000164, 0.000126, 0.00011, 0.000062, 0.00006, 0.000056, 0.000047, 0.000042, 0.00004, 0.000037, 0.000035, 0.000023];
for (let i = 0; i < A.length; i++) jde += coef[i] * s((A[i][0] + A[i][1] * k + A[i][2] * T2) * D2R);
return jde;
}
/** KST 날짜(JDN) 이하에서 가장 늦은 합삭의 k */
function newMoonKOnOrBefore(dayJdn) {
let k = Math.floor((dayJdn - 2451550.09766) / 29.530588861);
while (kstDayOf(jdeToUt(newMoonJde(k + 1))) <= dayJdn) k += 1;
while (kstDayOf(jdeToUt(newMoonJde(k))) > dayJdn) k -= 1;
return k;
}
// ---------- 세(歲: 동지 → 다음 동지) 단위로 음력 달 만들기 ----------
const suiCache = new Map();
/**
* 연도 Y 의 세: (Y−1)년 동지가 든 달(11월)부터 Y년 동지가 든 달 직전까지의 음력 달 목록.
* 달이 13개면 중기가 없는 첫 달을 윤달로 둔다. 각 달: { month, leap, start(JDN), end(JDN, 다음 달 1일) }
*/
function sui(Y) {
if (suiCache.has(Y)) return suiCache.get(Y);
const ws1 = kstDayOf(jdeToUt(termJde(Y - 1, 270))); const ws2 = kstDayOf(jdeToUt(termJde(Y, 270)));
const k0 = newMoonKOnOrBefore(ws1); const starts = [];
for (let k = k0; ; k++) { const s = kstDayOf(jdeToUt(newMoonJde(k))); if (s > ws2) break; starts.push(s); }
// starts 의 마지막은 Y년 동지가 든 달(다음 세의 11월) 시작 — 이번 세의 끝으로만 쓴다
const n = starts.length - 1;
// 중기(황경 30° 배수) 날짜: (Y−1) 동지, Y 년 대한(300)·우수(330)·춘분(0)·…·소설(240), Y 동지
const majors = [ws1]; for (const lon of [300, 330, 0, 30, 60, 90, 120, 150, 180, 210, 240]) majors.push(kstDayOf(jdeToUt(termJde(Y, lon)))); majors.push(ws2);
const hasMajor = (i) => majors.some((t) => t >= starts[i] && t < starts[i + 1]);
let leapIdx = -1; if (n === 13) { for (let i = 1; i < n; i++) if (!hasMajor(i)) { leapIdx = i; break; } }
const months = []; let num = 10; // 첫 달이 11월이 되도록
for (let i = 0; i < n; i++) {
if (i === leapIdx) months.push({ month: num, leap: true, start: starts[i], end: starts[i + 1] });
else { num = num % 12 + 1; months.push({ month: num, leap: false, start: starts[i], end: starts[i + 1] }); }
}
suiCache.set(Y, months); return months;
}
const yearCache = new Map();
/** 음력 ly 년의 달 목록(1월 … 12월, 윤달 포함) — 세(Y)의 1~10월 + 세(Y+1)의 11·12월 */
function lunarYear(ly) {
if (yearCache.has(ly)) return yearCache.get(ly);
const a = sui(ly); const b = sui(ly + 1);
const i1 = a.findIndex((m) => m.month === 1 && !m.leap); const j1 = b.findIndex((m) => m.month === 1 && !m.leap);
const months = a.slice(i1).concat(b.slice(0, j1)).map((m) => ({ month: m.month, leap: m.leap, start: m.start, end: m.end, days: m.end - m.start }));
yearCache.set(ly, months); return months;
}
function checkRange(y) { if (!(y >= RANGE[0] && y <= RANGE[1])) throw new Error(`음력 계산은 ${RANGE[0]}~${RANGE[1]}년만 지원합니다`); }
/**
* 양력 → 음력.
* @returns {{ year:number, month:number, day:number, leap:boolean, monthDays:number }}
*/
function solarToLunar(y, m, d) {
checkRange(y); const j = jdn(y, m, d);
for (const ly of [y, y - 1, y + 1]) {
const ms = lunarYear(ly); const hit = ms.find((x) => j >= x.start && j < x.end);
if (hit) return { year: ly, month: hit.month, day: j - hit.start + 1, leap: hit.leap, monthDays: hit.days };
}
throw new Error('음력 변환 실패');
}
/**
* 음력 → 양력. 윤달을 요청했는데 그 해에 그 윤달이 없으면 평달로 본다.
* @param {boolean} clamp 그 달에 없는 날(30일)이면 마지막 날(29일)로 당길지 — 음력 생일처럼 해마다 반복하는 일정에 쓴다
* @returns {{ y, m, d }|null}
*/
function lunarToSolar(ly, lm, ld, leap = false, clamp = false) {
checkRange(ly); const ms = lunarYear(ly);
const mo = ms.find((x) => x.month === lm && x.leap === !!leap) || ms.find((x) => x.month === lm && !x.leap); if (!mo) return null;
let day = ld; if (day > mo.days) { if (!clamp) return null; day = mo.days; } if (day < 1) return null;
return dateFromJdn(mo.start + day - 1);
}
/** 음력 한 해의 달 목록 (관리 화면·검증용) */
function lunarYearMonths(ly) { checkRange(ly); return lunarYear(ly).map((m) => ({ month: m.month, leap: m.leap, days: m.days, start: dateFromJdn(m.start) })); }
// ---------- 간지 ----------
const STEMS_H = '갑을병정무기경신임계'; const STEMS_C = '甲乙丙丁戊己庚辛壬癸';
const BRANCH_H = '자축인묘진사오미신유술해'; const BRANCH_C = '子丑寅卯辰巳午未申酉戌亥';
const ZODIAC = ['쥐', '소', '호랑이', '토끼', '용', '뱀', '말', '양', '원숭이', '닭', '개', '돼지'];
/** 60갑자 번호(0=갑자) → { idx, hangul, hanja } */
const gz = (idx) => { const i = ((idx % 60) + 60) % 60; return { idx: i, hangul: STEMS_H[i % 10] + BRANCH_H[i % 12], hanja: STEMS_C[i % 10] + BRANCH_C[i % 12] }; };
/** 절기 이름 (황경 순: 소한 285° 부터) */
const TERM_NAMES = { 285: '소한', 300: '대한', 315: '입춘', 330: '우수', 345: '경칩', 0: '춘분', 15: '청명', 30: '곡우', 45: '입하', 60: '소만', 75: '망종', 90: '하지', 105: '소서', 120: '대서', 135: '입추', 150: '처서', 165: '백로', 180: '추분', 195: '한로', 210: '상강', 225: '입동', 240: '소설', 255: '대설', 270: '동지' };
/** 절기 한자 이름 (한국어가 아닌 페이지에 표시) */
const TERM_HANJA = { 285: '小寒', 300: '大寒', 315: '立春', 330: '雨水', 345: '驚蟄', 0: '春分', 15: '淸明', 30: '穀雨', 45: '立夏', 60: '小滿', 75: '芒種', 90: '夏至', 105: '小暑', 120: '大暑', 135: '立秋', 150: '處暑', 165: '白露', 180: '秋分', 195: '寒露', 210: '霜降', 225: '立冬', 240: '小雪', 255: '大雪', 270: '冬至' };
const TERM_ORDER = [285, 300, 315, 330, 345, 0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270];
const termCache = new Map();
/** 그 해 24절기의 KST 날짜(JDN)와 시각. [{ name, lon, jdn, at }] 황경 순(소한 → 동지) */
function termsOfYear(year) {
if (termCache.has(year)) return termCache.get(year);
const out = TERM_ORDER.map((lon) => { const ut = jdeToUt(termJde(year, lon)); return { name: TERM_NAMES[lon], hanja: TERM_HANJA[lon], lon, jdn: kstDayOf(ut), at: isoKst(ut) }; });
termCache.set(year, out); return out;
}
/** 그 해 24절기 (공개용) */
function solarTerms(year) { checkRange(year); return termsOfYear(year).map((t) => { const { y, m, d } = dateFromJdn(t.jdn); return { name: t.name, hanja: t.hanja, lon: t.lon, at: t.at, date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` }; }); }
/**
* 사주식 간지 (연주·월주·일주). 날짜 단위라 절입 당일부터 새 달·새 해로 본다.
* @returns {{ year, month, day, text, textHanja, zodiac }}
*/
function ganji(y, m, d) {
checkRange(y); const j = jdn(y, m, d);
const terms = termsOfYear(y);
// 연주: 입춘(315°) 전이면 전년도 간지. (연도 − 4) mod 60 → 1984 = 갑자년
const ipchun = terms.find((t) => t.lon === 315).jdn; const pillarYear = j < ipchun ? y - 1 : y;
const yearGz = gz(pillarYear - 4);
// 월주: 이 날 이전(당일 포함)의 마지막 절기(15°+30°k). 전년도 절기(대설·소한 등)까지 보아야 1월 초를 처리할 수 있다
const nodes = [...termsOfYear(y - 1), ...terms].filter((t) => t.lon % 30 === 15 && t.jdn <= j);
const last = nodes[nodes.length - 1];
const k = ((last.lon - 315 + 360) % 360) / 30; // 인월=0 … 축월=11
const monthStem = ((yearGz.idx % 10) % 5 * 2 + 2 + k) % 10; const monthBranch = (2 + k) % 12; // 오호둔: 월간, 월지(인=2)
const month = gz(6 * monthStem - 5 * monthBranch); // 천간 s·지지 b 를 모두 만족하는 60갑자 번호 = (6s − 5b) mod 60
const day = gz(j + 49); // 일주: 2000-01-01(JDN 2451545) = 무오(54)
return { year: yearGz, month, day, text: `${yearGz.hangul}년 ${month.hangul}월 ${day.hangul}일`, textHanja: `${yearGz.hanja}年 ${month.hanja}月 ${day.hanja}日`, zodiac: ZODIAC[yearGz.idx % 12] };
}
/**
* 캘린더 한 칸에 보일 정보: 음력 날짜, 간지, 그날 절기.
* @returns {{ date, lunar:{year,month,day,leap,monthDays,text}, ganji, term:string|null }}
*/
function dayInfo(y, m, d) {
const lunar = solarToLunar(y, m, d); const g = ganji(y, m, d); const j = jdn(y, m, d);
const term = termsOfYear(y).find((t) => t.jdn === j);
return { date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`, lunar: { ...lunar, text: `${lunar.leap ? '윤' : ''}${lunar.month}.${lunar.day}` }, ganji: g, term: term ? term.name : null, termHanja: term ? term.hanja : null };
}
module.exports = { solarToLunar, lunarToSolar, lunarYearMonths, ganji, dayInfo, solarTerms, jdn, dateFromJdn, RANGE, STEMS_H, BRANCH_H, STEMS_C, BRANCH_C, ZODIAC };