Files

263 lines
22 KiB
JavaScript
Raw Permalink Normal View History

'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 };