@thyrith/momentkh 2.5.5 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/momentkh.ts ADDED
@@ -0,0 +1,1341 @@
1
+ /**
2
+ * MomentKH - Standalone Khmer Calendar Library (TypeScript)
3
+ *
4
+ * A simplified, standalone library for Khmer calendar conversion
5
+ * No dependencies required
6
+ *
7
+ * Based on:
8
+ * - khmer_calendar.cpp implementation
9
+ * - Original momentkh library
10
+ *
11
+ * @version 2.0.0
12
+ * @license MIT
13
+ */
14
+
15
+ // ============================================================================
16
+ // Type Definitions
17
+ // ============================================================================
18
+
19
+ // Enums for better type safety and ease of use
20
+
21
+ export enum MoonPhase {
22
+ Waxing = 0, // កើត
23
+ Waning = 1 // រោច
24
+ }
25
+
26
+ export enum MonthIndex {
27
+ Migasir = 0, // មិគសិរ
28
+ Boss = 1, // បុស្ស
29
+ Meak = 2, // មាឃ
30
+ Phalkun = 3, // ផល្គុន
31
+ Cheit = 4, // ចេត្រ
32
+ Pisakh = 5, // ពិសាខ
33
+ Jesth = 6, // ជេស្ឋ
34
+ Asadh = 7, // អាសាឍ
35
+ Srap = 8, // ស្រាពណ៍
36
+ Phatrabot = 9, // ភទ្របទ
37
+ Assoch = 10, // អស្សុជ
38
+ Kadeuk = 11, // កត្ដិក
39
+ Pathamasadh = 12, // បឋមាសាឍ
40
+ Tutiyasadh = 13 // ទុតិយាសាឍ
41
+ }
42
+
43
+ export enum AnimalYear {
44
+ Chhut = 0, // ជូត - Rat
45
+ Chlov = 1, // ឆ្លូវ - Ox
46
+ Khal = 2, // ខាល - Tiger
47
+ Thos = 3, // ថោះ - Rabbit
48
+ Rong = 4, // រោង - Dragon
49
+ Masagn = 5, // ម្សាញ់ - Snake
50
+ Momee = 6, // មមី - Horse
51
+ Momae = 7, // មមែ - Goat
52
+ Vok = 8, // វក - Monkey
53
+ Roka = 9, // រកា - Rooster
54
+ Cho = 10, // ច - Dog
55
+ Kor = 11 // កុរ - Pig
56
+ }
57
+
58
+ export enum EraYear {
59
+ SamridhiSak = 0, // សំរឹទ្ធិស័ក
60
+ AekSak = 1, // ឯកស័ក
61
+ ToSak = 2, // ទោស័ក
62
+ TreiSak = 3, // ត្រីស័ក
63
+ ChattvaSak = 4, // ចត្វាស័ក
64
+ PanchaSak = 5, // បញ្ចស័ក
65
+ ChhaSak = 6, // ឆស័ក
66
+ SappaSak = 7, // សប្តស័ក
67
+ AtthaSak = 8, // អដ្ឋស័ក
68
+ NappaSak = 9 // នព្វស័ក
69
+ }
70
+
71
+ export enum DayOfWeek {
72
+ Sunday = 0, // អាទិត្យ
73
+ Monday = 1, // ចន្ទ
74
+ Tuesday = 2, // អង្គារ
75
+ Wednesday = 3, // ពុធ
76
+ Thursday = 4, // ព្រហស្បតិ៍
77
+ Friday = 5, // សុក្រ
78
+ Saturday = 6 // សៅរ៍
79
+ }
80
+
81
+ export interface GregorianDate {
82
+ year: number;
83
+ month: number;
84
+ day: number;
85
+ hour?: number;
86
+ minute?: number;
87
+ second?: number;
88
+ dayOfWeek?: number;
89
+ }
90
+
91
+ export interface KhmerDateInfo {
92
+ day: number; // 1-15
93
+ moonPhase: MoonPhase; // Enum: MoonPhase.Waxing or MoonPhase.Waning
94
+ moonPhaseName: string; // String name: "កើត" or "រោច"
95
+ monthIndex: MonthIndex; // Enum: MonthIndex (0-13)
96
+ monthName: string; // String name of the month
97
+ beYear: number; // Buddhist Era year
98
+ jsYear: number; // Jolak Sakaraj year
99
+ animalYear: AnimalYear; // Enum: AnimalYear (0-11)
100
+ animalYearName: string; // String name of animal year
101
+ eraYear: EraYear; // Enum: EraYear (0-9)
102
+ eraYearName: string; // String name of era year
103
+ dayOfWeek: DayOfWeek; // Enum: DayOfWeek (0-6)
104
+ dayOfWeekName: string; // String name of day of week
105
+ }
106
+
107
+ export interface KhmerConversionResult {
108
+ gregorian: {
109
+ year: number;
110
+ month: number;
111
+ day: number;
112
+ hour: number;
113
+ minute: number;
114
+ second: number;
115
+ dayOfWeek: number;
116
+ };
117
+ khmer: KhmerDateInfo;
118
+ _khmerDateObj: KhmerDate;
119
+ }
120
+
121
+ export interface NewYearInfo {
122
+ year: number;
123
+ month: number;
124
+ day: number;
125
+ hour: number;
126
+ minute: number;
127
+ }
128
+
129
+ export interface TimeInfo {
130
+ hour: number;
131
+ minute: number;
132
+ }
133
+
134
+ export interface SunInfo {
135
+ sunInaugurationAsLibda: number;
136
+ reasey: number;
137
+ angsar: number;
138
+ libda: number;
139
+ }
140
+
141
+ export interface NewYearInfoInternal {
142
+ timeOfNewYear: TimeInfo;
143
+ numberOfVanabatDays: number;
144
+ newYearsDaySotins: SunInfo[];
145
+ }
146
+
147
+ export interface NewYearFullInfo {
148
+ newYearMoment: Date;
149
+ lerngSakMoment: Date;
150
+ newYearInfo: NewYearInfo;
151
+ }
152
+
153
+ export interface MoonDayInfo {
154
+ day: number;
155
+ moonPhase: MoonPhase;
156
+ }
157
+
158
+ export interface Constants {
159
+ LunarMonths: Record<string, number>;
160
+ LunarMonthNames: string[];
161
+ SolarMonthNames: string[];
162
+ AnimalYearNames: string[];
163
+ EraYearNames: string[];
164
+ WeekdayNames: string[];
165
+ MoonStatusNames: string[];
166
+ }
167
+
168
+ // ============================================================================
169
+ // Constants and Locale Data
170
+ // ============================================================================
171
+
172
+ const LunarMonths: Record<string, number> = {
173
+ 'មិគសិរ': 0, 'បុស្ស': 1, 'មាឃ': 2, 'ផល្គុន': 3,
174
+ 'ចេត្រ': 4, 'ពិសាខ': 5, 'ជេស្ឋ': 6, 'អាសាឍ': 7,
175
+ 'ស្រាពណ៍': 8, 'ភទ្របទ': 9, 'អស្សុជ': 10, 'កត្ដិក': 11,
176
+ 'បឋមាសាឍ': 12, 'ទុតិយាសាឍ': 13
177
+ };
178
+
179
+ const LunarMonthNames: string[] = [
180
+ 'មិគសិរ', 'បុស្ស', 'មាឃ', 'ផល្គុន', 'ចេត្រ', 'ពិសាខ',
181
+ 'ជេស្ឋ', 'អាសាឍ', 'ស្រាពណ៍', 'ភទ្របទ', 'អស្សុជ', 'កត្ដិក',
182
+ 'បឋមាសាឍ', 'ទុតិយាសាឍ'
183
+ ];
184
+
185
+ const SolarMonthNames: string[] = [
186
+ 'មករា', 'កុម្ភៈ', 'មីនា', 'មេសា', 'ឧសភា', 'មិថុនា',
187
+ 'កក្កដា', 'សីហា', 'កញ្ញា', 'តុលា', 'វិច្ឆិកា', 'ធ្នូ'
188
+ ];
189
+
190
+ const AnimalYearNames: string[] = [
191
+ 'ជូត', 'ឆ្លូវ', 'ខាល', 'ថោះ', 'រោង', 'ម្សាញ់',
192
+ 'មមី', 'មមែ', 'វក', 'រកា', 'ច', 'កុរ'
193
+ ];
194
+
195
+ const EraYearNames: string[] = [
196
+ 'សំរឹទ្ធិស័ក', 'ឯកស័ក', 'ទោស័ក', 'ត្រីស័ក', 'ចត្វាស័ក',
197
+ 'បញ្ចស័ក', 'ឆស័ក', 'សប្តស័ក', 'អដ្ឋស័ក', 'នព្វស័ក'
198
+ ];
199
+
200
+ const WeekdayNames: string[] = [
201
+ 'អាទិត្យ', 'ចន្ទ', 'អង្គារ', 'ពុធ', 'ព្រហស្បតិ៍', 'សុក្រ', 'សៅរ៍'
202
+ ];
203
+
204
+ const WeekdayNamesShort: string[] = ['អា', 'ច', 'អ', 'ព', 'ព្រ', 'សុ', 'ស'];
205
+
206
+ const MoonStatusNames: string[] = ['កើត', 'រោច'];
207
+ const MoonStatusShort: string[] = ['ក', 'រ'];
208
+
209
+ const MoonDaySymbols: string[] = [
210
+ '᧡', '᧢', '᧣', '᧤', '᧥', '᧦', '᧧', '᧨', '᧩', '᧪',
211
+ '᧫', '᧬', '᧭', '᧮', '᧯', '᧱', '᧲', '᧳', '᧴', '᧵',
212
+ '᧶', '᧷', '᧸', '᧹', '᧺', '᧻', '᧼', '᧽', '᧾', '᧿'
213
+ ];
214
+
215
+ const KhmerNumerals: Record<string, string> = {
216
+ '0': '០', '1': '១', '2': '២', '3': '៣', '4': '៤',
217
+ '5': '៥', '6': '៦', '7': '៧', '8': '៨', '9': '៩'
218
+ };
219
+
220
+ // Exceptional New Year moments (cached values for specific years)
221
+ const khNewYearMoments: Record<string, string> = {
222
+ '1879': '12-04-1879 11:36',
223
+ '1897': '13-04-1897 02:00',
224
+ '2011': '14-04-2011 13:12',
225
+ '2012': '14-04-2012 19:11',
226
+ '2013': '14-04-2013 02:12',
227
+ '2014': '14-04-2014 08:07',
228
+ '2015': '14-04-2015 14:02',
229
+ '2024': '13-04-2024 22:17',
230
+ };
231
+
232
+ // ============================================================================
233
+ // Utility Functions
234
+ // ============================================================================
235
+
236
+ function toKhmerNumeral(num: number | string): string {
237
+ return String(num).replace(/\d/g, d => KhmerNumerals[d]);
238
+ }
239
+
240
+ function isGregorianLeapYear(year: number): boolean {
241
+ return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
242
+ }
243
+
244
+ function getDaysInGregorianMonth(year: number, month: number): number {
245
+ const daysInMonth: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
246
+ if (month === 2 && isGregorianLeapYear(year)) {
247
+ return 29;
248
+ }
249
+ return daysInMonth[month - 1];
250
+ }
251
+
252
+ // Julian Day Number conversion
253
+ function gregorianToJulianDay(year: number, month: number, day: number): number {
254
+ const a = Math.floor((14 - month) / 12);
255
+ const y = year + 4800 - a;
256
+ const m = month + 12 * a - 3;
257
+ return day + Math.floor((153 * m + 2) / 5) + 365 * y +
258
+ Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045;
259
+ }
260
+
261
+ function julianDayToGregorian(jdn: number): { year: number; month: number; day: number } {
262
+ const a = jdn + 32044;
263
+ const b = Math.floor((4 * a + 3) / 146097);
264
+ const c = a - Math.floor((146097 * b) / 4);
265
+ const d = Math.floor((4 * c + 3) / 1461);
266
+ const e = c - Math.floor((1461 * d) / 4);
267
+ const m = Math.floor((5 * e + 2) / 153);
268
+ const day = e - Math.floor((153 * m + 2) / 5) + 1;
269
+ const month = m + 3 - 12 * Math.floor(m / 10);
270
+ const year = 100 * b + d - 4800 + Math.floor(m / 10);
271
+ return { year, month, day };
272
+ }
273
+
274
+ function getDayOfWeek(year: number, month: number, day: number): number {
275
+ const jdn = gregorianToJulianDay(year, month, day);
276
+ // JDN % 7: where 0=Monday, 1=Tuesday, ..., 6=Sunday
277
+ // We want: 0=Sunday, 1=Monday, ..., 6=Saturday
278
+ // So we need to convert: (jdn + 1) % 7
279
+ return (jdn + 1) % 7;
280
+ }
281
+
282
+ // ============================================================================
283
+ // Input Validation Functions
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Validates Gregorian date parameters
288
+ * @throws {Error} If any parameter is invalid
289
+ */
290
+ function validateGregorianDate(
291
+ year: number,
292
+ month: number,
293
+ day: number,
294
+ hour: number = 0,
295
+ minute: number = 0,
296
+ second: number = 0
297
+ ): void {
298
+ // Validate types
299
+ if (typeof year !== 'number' || isNaN(year)) {
300
+ throw new Error(`Invalid year: ${year}. Year must be a valid number.`);
301
+ }
302
+ if (typeof month !== 'number' || isNaN(month)) {
303
+ throw new Error(`Invalid month: ${month}. Month must be a valid number.`);
304
+ }
305
+ if (typeof day !== 'number' || isNaN(day)) {
306
+ throw new Error(`Invalid day: ${day}. Day must be a valid number.`);
307
+ }
308
+ if (typeof hour !== 'number' || isNaN(hour)) {
309
+ throw new Error(`Invalid hour: ${hour}. Hour must be a valid number.`);
310
+ }
311
+ if (typeof minute !== 'number' || isNaN(minute)) {
312
+ throw new Error(`Invalid minute: ${minute}. Minute must be a valid number.`);
313
+ }
314
+ if (typeof second !== 'number' || isNaN(second)) {
315
+ throw new Error(`Invalid second: ${second}. Second must be a valid number.`);
316
+ }
317
+
318
+ // Validate month range (1-12)
319
+ if (month < 1 || month > 12) {
320
+ throw new Error(`Invalid month: ${month}. Month must be between 1 and 12.`);
321
+ }
322
+
323
+ // Validate day range for the specific month/year
324
+ const daysInMonth = getDaysInGregorianMonth(year, month);
325
+ if (day < 1 || day > daysInMonth) {
326
+ const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
327
+ 'July', 'August', 'September', 'October', 'November', 'December'];
328
+ throw new Error(
329
+ `Invalid day: ${day}. ${monthNames[month - 1]} ${year} has ${daysInMonth} days.`
330
+ );
331
+ }
332
+
333
+ // Validate hour (0-23)
334
+ if (hour < 0 || hour > 23) {
335
+ throw new Error(`Invalid hour: ${hour}. Hour must be between 0 and 23.`);
336
+ }
337
+
338
+ // Validate minute (0-59)
339
+ if (minute < 0 || minute > 59) {
340
+ throw new Error(`Invalid minute: ${minute}. Minute must be between 0 and 59.`);
341
+ }
342
+
343
+ // Validate second (0-59)
344
+ if (second < 0 || second > 59) {
345
+ throw new Error(`Invalid second: ${second}. Second must be between 0 and 59.`);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Validates Khmer date parameters
351
+ * @throws {Error} If any parameter is invalid
352
+ */
353
+ function validateKhmerDate(
354
+ day: number,
355
+ moonPhase: MoonPhase | number,
356
+ monthIndex: MonthIndex | number,
357
+ beYear: number
358
+ ): void {
359
+ // Validate types
360
+ if (typeof day !== 'number' || isNaN(day)) {
361
+ throw new Error(`Invalid day: ${day}. Day must be a valid number.`);
362
+ }
363
+ if (typeof moonPhase !== 'number' || isNaN(moonPhase)) {
364
+ throw new Error(`Invalid moonPhase: ${moonPhase}. moonPhase must be a valid number.`);
365
+ }
366
+ if (typeof monthIndex !== 'number' || isNaN(monthIndex)) {
367
+ throw new Error(`Invalid monthIndex: ${monthIndex}. monthIndex must be a valid number.`);
368
+ }
369
+ if (typeof beYear !== 'number' || isNaN(beYear)) {
370
+ throw new Error(`Invalid beYear: ${beYear}. beYear must be a valid number.`);
371
+ }
372
+
373
+ // Validate day (1-15)
374
+ if (day < 1 || day > 15) {
375
+ throw new Error(
376
+ `Invalid day: ${day}. Lunar day must be between 1 and 15.`
377
+ );
378
+ }
379
+
380
+ // Validate moonPhase (0 = Waxing, 1 = Waning)
381
+ const moonPhaseNum = typeof moonPhase === 'number' ? moonPhase : moonPhase as number;
382
+ if (moonPhaseNum !== 0 && moonPhaseNum !== 1) {
383
+ throw new Error(
384
+ `Invalid moonPhase: ${moonPhase}. moonPhase must be 0 (Waxing/កើត) or 1 (Waning/រោច).`
385
+ );
386
+ }
387
+
388
+ // Validate monthIndex (0-13)
389
+ const monthIndexNum = typeof monthIndex === 'number' ? monthIndex : monthIndex as number;
390
+ if (monthIndexNum < 0 || monthIndexNum > 13) {
391
+ throw new Error(
392
+ `Invalid monthIndex: ${monthIndex}. monthIndex must be between 0 and 13.`
393
+ );
394
+ }
395
+
396
+ // Validate beYear (reasonable range: 2000-3000)
397
+ if (beYear < 2000 || beYear > 3000) {
398
+ throw new Error(
399
+ `Invalid beYear: ${beYear}. beYear must be between 2000 and 3000.`
400
+ );
401
+ }
402
+
403
+ // Additional validation: check if leap months (12, 13) are used in non-leap years
404
+ // This is done in the conversion function since it requires more complex logic
405
+ }
406
+
407
+ /**
408
+ * Validates JavaScript Date object
409
+ * @throws {Error} If Date object is invalid
410
+ */
411
+ function validateDateObject(date: Date): void {
412
+ if (!(date instanceof Date)) {
413
+ throw new Error('Invalid input: Expected a Date object.');
414
+ }
415
+ if (isNaN(date.getTime())) {
416
+ throw new Error('Invalid Date object: Date is not a valid date.');
417
+ }
418
+ }
419
+
420
+
421
+ // ============================================================================
422
+ // Era Conversions
423
+ // ============================================================================
424
+
425
+ function adToJs(adYear: number): number {
426
+ return adYear - 638;
427
+ }
428
+
429
+ function adToBe(adYear: number): number {
430
+ return adYear + 544;
431
+ }
432
+
433
+ function beToAd(beYear: number): number {
434
+ return beYear - 544;
435
+ }
436
+
437
+ function jsToAd(jsYear: number): number {
438
+ return jsYear + 638;
439
+ }
440
+
441
+ function beToJs(beYear: number): number {
442
+ return beYear - 1182;
443
+ }
444
+
445
+ function jsToBe(jsYear: number): number {
446
+ return jsYear + 1182;
447
+ }
448
+
449
+ // ============================================================================
450
+ // Calendar Calculation Functions
451
+ // ============================================================================
452
+
453
+ function getAharkun(beYear: number): number {
454
+ return Math.floor((beYear * 292207 + 499) / 800) + 4;
455
+ }
456
+
457
+ function getAharkunMod(beYear: number): number {
458
+ return (beYear * 292207 + 499) % 800;
459
+ }
460
+
461
+ function getKromthupul(beYear: number): number {
462
+ return 800 - getAharkunMod(beYear);
463
+ }
464
+
465
+ function getAvoman(beYear: number): number {
466
+ return (getAharkun(beYear) * 11 + 25) % 692;
467
+ }
468
+
469
+ function getBodithey(beYear: number): number {
470
+ const aharkun = getAharkun(beYear);
471
+ return (Math.floor((aharkun * 11 + 25) / 692) + aharkun + 29) % 30;
472
+ }
473
+
474
+ function isKhmerSolarLeap(beYear: number): boolean {
475
+ return getKromthupul(beYear) <= 207;
476
+ }
477
+
478
+ function isKhmerLeapDayByCalculation(beYear: number): boolean {
479
+ const avoman = getAvoman(beYear);
480
+ const isSolarLeap = isKhmerSolarLeap(beYear);
481
+
482
+ if (avoman === 0 && getAvoman(beYear - 1) === 137) {
483
+ return true;
484
+ } else if (isSolarLeap) {
485
+ return avoman < 127;
486
+ } else if (avoman === 137 && getAvoman(beYear + 1) === 0) {
487
+ return false;
488
+ } else if (avoman < 138) {
489
+ return true;
490
+ }
491
+ return false;
492
+ }
493
+
494
+ function isKhmerLeapMonth(beYear: number): boolean {
495
+ const bodithey = getBodithey(beYear);
496
+ const boditheyNextYear = getBodithey(beYear + 1);
497
+
498
+ if (bodithey === 25 && boditheyNextYear === 5) {
499
+ return false;
500
+ }
501
+
502
+ return (bodithey === 24 && boditheyNextYear === 6) ||
503
+ (bodithey >= 25) ||
504
+ (bodithey < 6);
505
+ }
506
+
507
+ function getLeapType(beYear: number): number {
508
+ if (isKhmerLeapMonth(beYear)) {
509
+ return 1; // Leap month (អធិកមាស)
510
+ } else if (isKhmerLeapDayByCalculation(beYear)) {
511
+ return 2; // Leap day (ចន្ទ្រាធិមាស)
512
+ } else if (isKhmerLeapMonth(beYear - 1)) {
513
+ let previousYear = beYear - 1;
514
+ while (true) {
515
+ if (isKhmerLeapDayByCalculation(previousYear)) {
516
+ return 2;
517
+ }
518
+ previousYear -= 1;
519
+ if (!isKhmerLeapMonth(previousYear)) {
520
+ return 0;
521
+ }
522
+ }
523
+ }
524
+ return 0; // Regular year
525
+ }
526
+
527
+ function getNumberOfDaysInKhmerMonth(monthIndex: MonthIndex | number, beYear: number): number {
528
+ const leapType = getLeapType(beYear);
529
+ const idx = typeof monthIndex === 'number' ? monthIndex : monthIndex as number;
530
+
531
+ if (idx === MonthIndex.Jesth && leapType === 2) { // ជេស្ឋ with leap day
532
+ return 30;
533
+ }
534
+ if (idx === MonthIndex.Pathamasadh || idx === MonthIndex.Tutiyasadh) { // បឋមាសាឍ, ទុតិយាសាឍ
535
+ return leapType === 1 ? 30 : 0;
536
+ }
537
+ // Alternating pattern: even months = 29 days, odd months = 30 days
538
+ // មិគសិរ:29, បុស្ស:30, មាឃ:29, ផល្គុន:30, ចេត្រ:29, ពិសាខ:30, ជេស្ឋ:29, អាសាឍ:30, etc.
539
+ return idx % 2 === 0 ? 29 : 30;
540
+ }
541
+
542
+ function getNumberOfDaysInKhmerYear(beYear: number): number {
543
+ const leapType = getLeapType(beYear);
544
+ if (leapType === 1) return 384; // Leap month
545
+ if (leapType === 2) return 355; // Leap day
546
+ return 354; // Regular
547
+ }
548
+
549
+ function nextMonthOf(monthIndex: MonthIndex | number, beYear: number): MonthIndex {
550
+ const leapType = getLeapType(beYear);
551
+ const idx = typeof monthIndex === 'number' ? monthIndex : monthIndex as number;
552
+
553
+ if (idx === MonthIndex.Jesth && leapType === 1) { // ជេស្ឋ in leap month year
554
+ return MonthIndex.Pathamasadh; // បឋមាសាឍ
555
+ }
556
+ if (idx === MonthIndex.Kadeuk) return MonthIndex.Migasir; // កត្ដិក -> មិគសិរ
557
+ if (idx === MonthIndex.Pathamasadh) return MonthIndex.Tutiyasadh; // បឋមាសាឍ -> ទុតិយាសាឍ
558
+ if (idx === MonthIndex.Tutiyasadh) return MonthIndex.Srap; // ទុតិយាសាឍ -> ស្រាពណ៍
559
+
560
+ return (idx + 1) as MonthIndex;
561
+ }
562
+
563
+ function previousMonthOf(monthIndex: MonthIndex | number, beYear: number): MonthIndex {
564
+ const leapType = getLeapType(beYear);
565
+ const idx = typeof monthIndex === 'number' ? monthIndex : monthIndex as number;
566
+
567
+ if (idx === MonthIndex.Migasir) return MonthIndex.Kadeuk; // មិគសិរ -> កត្ដិក
568
+ if (idx === MonthIndex.Srap && leapType === 1) return MonthIndex.Tutiyasadh; // ស្រាពណ៍ -> ទុតិយាសាឍ (leap)
569
+ if (idx === MonthIndex.Tutiyasadh) return MonthIndex.Pathamasadh; // ទុតិយាសាឍ -> បឋមាសាឍ
570
+ if (idx === MonthIndex.Pathamasadh) return MonthIndex.Jesth; // បឋមាសាឍ -> ជេស្ឋ
571
+
572
+ return (idx - 1) as MonthIndex;
573
+ }
574
+
575
+ // ============================================================================
576
+ // Khmer New Year Calculation (JS Year based)
577
+ // ============================================================================
578
+
579
+ function getAharkunJs(jsYear: number): number {
580
+ const h = jsYear * 292207 + 373;
581
+ return Math.floor(h / 800) + 1;
582
+ }
583
+
584
+ function getAvomanJs(jsYear: number): number {
585
+ return (getAharkunJs(jsYear) * 11 + 650) % 692;
586
+ }
587
+
588
+ function getKromthupulJs(jsYear: number): number {
589
+ return 800 - ((292207 * jsYear + 373) % 800);
590
+ }
591
+
592
+ function getBoditheyJs(jsYear: number): number {
593
+ const aharkun = getAharkunJs(jsYear);
594
+ const a = 11 * aharkun + 650;
595
+ return (aharkun + Math.floor(a / 692)) % 30;
596
+ }
597
+
598
+ function isAdhikameas(jsYear: number): boolean {
599
+ const bodithey = getBoditheyJs(jsYear);
600
+ const boditheyNext = getBoditheyJs(jsYear + 1);
601
+
602
+ if (bodithey === 24 && boditheyNext === 6) return true;
603
+ if (bodithey === 25 && boditheyNext === 5) return false;
604
+ return bodithey > 24 || bodithey < 6;
605
+ }
606
+
607
+ function isChantrathimeas(jsYear: number): boolean {
608
+ const avoman = getAvomanJs(jsYear);
609
+ const avomanNext = getAvomanJs(jsYear + 1);
610
+ const avomanPrev = getAvomanJs(jsYear - 1);
611
+ const isSolarLeap = getKromthupulJs(jsYear) <= 207;
612
+
613
+ if (avoman === 0 && avomanPrev === 137) return true;
614
+ if (isSolarLeap) return avoman < 127;
615
+ if (avoman === 137 && avomanNext === 0) return false;
616
+ if (!isSolarLeap && avoman < 138) return true;
617
+ if (avomanPrev === 137 && avoman === 0) return true;
618
+
619
+ return false;
620
+ }
621
+
622
+ function has366Days(jsYear: number): boolean {
623
+ return getKromthupulJs(jsYear) <= 207;
624
+ }
625
+
626
+ function getSunInfo(jsYear: number, sotin: number): SunInfo {
627
+ const infoOfPrevYear = {
628
+ kromathopol: getKromthupulJs(jsYear - 1)
629
+ };
630
+
631
+ // Sun average as Libda
632
+ const r2 = 800 * sotin + infoOfPrevYear.kromathopol;
633
+ const reasey = Math.floor(r2 / 24350);
634
+ const r3 = r2 % 24350;
635
+ const angsar = Math.floor(r3 / 811);
636
+ const r4 = r3 % 811;
637
+ const l1 = Math.floor(r4 / 14);
638
+ const libda = l1 - 3;
639
+ const sunAverageAsLibda = (30 * 60 * reasey) + (60 * angsar) + libda;
640
+
641
+ // Left over
642
+ const s1 = ((30 * 60 * 2) + (60 * 20));
643
+ let leftOver = sunAverageAsLibda - s1;
644
+ if (sunAverageAsLibda < s1) {
645
+ leftOver += (30 * 60 * 12);
646
+ }
647
+
648
+ const kaen = Math.floor(leftOver / (30 * 60));
649
+
650
+ // Last left over
651
+ let rs = -1;
652
+ if ([0, 1, 2].includes(kaen)) {
653
+ rs = kaen;
654
+ } else if ([3, 4, 5].includes(kaen)) {
655
+ rs = (30 * 60 * 6) - leftOver;
656
+ } else if ([6, 7, 8].includes(kaen)) {
657
+ rs = leftOver - (30 * 60 * 6);
658
+ } else if ([9, 10, 11].includes(kaen)) {
659
+ rs = ((30 * 60 * 11) + (60 * 29) + 60) - leftOver;
660
+ }
661
+
662
+ const lastLeftOver = {
663
+ reasey: Math.floor(rs / (30 * 60)),
664
+ angsar: Math.floor((rs % (30 * 60)) / 60),
665
+ libda: rs % 60
666
+ };
667
+
668
+ // Khan and pouichalip
669
+ let khan: number, pouichalip: number;
670
+ if (lastLeftOver.angsar >= 15) {
671
+ khan = 2 * lastLeftOver.reasey + 1;
672
+ pouichalip = 60 * (lastLeftOver.angsar - 15) + lastLeftOver.libda;
673
+ } else {
674
+ khan = 2 * lastLeftOver.reasey;
675
+ pouichalip = 60 * lastLeftOver.angsar + lastLeftOver.libda;
676
+ }
677
+
678
+ // Chhaya sun
679
+ const chhayaSunMap = [
680
+ { multiplicity: 35, chhaya: 0 },
681
+ { multiplicity: 32, chhaya: 35 },
682
+ { multiplicity: 27, chhaya: 67 },
683
+ { multiplicity: 22, chhaya: 94 },
684
+ { multiplicity: 13, chhaya: 116 },
685
+ { multiplicity: 5, chhaya: 129 }
686
+ ];
687
+ const chhayaSun = khan <= 5 ? chhayaSunMap[khan] : { multiplicity: 0, chhaya: 134 };
688
+
689
+ const q = Math.floor((pouichalip * chhayaSun.multiplicity) / 900);
690
+ const pholAsLibda = q + chhayaSun.chhaya;
691
+
692
+ // Sun inauguration
693
+ const sunInaugurationAsLibda = kaen <= 5
694
+ ? sunAverageAsLibda - pholAsLibda
695
+ : sunAverageAsLibda + pholAsLibda;
696
+
697
+ return {
698
+ sunInaugurationAsLibda,
699
+ reasey: Math.floor(sunInaugurationAsLibda / (30 * 60)),
700
+ angsar: Math.floor((sunInaugurationAsLibda % (30 * 60)) / 60),
701
+ libda: sunInaugurationAsLibda % 60
702
+ };
703
+ }
704
+
705
+ function getNewYearInfo(jsYear: number): NewYearInfoInternal {
706
+ const sotins = has366Days(jsYear - 1)
707
+ ? [363, 364, 365, 366]
708
+ : [362, 363, 364, 365];
709
+
710
+ const newYearsDaySotins = sotins.map(sotin => getSunInfo(jsYear, sotin));
711
+
712
+ // Find time of new year
713
+ let timeOfNewYear: TimeInfo = { hour: 0, minute: 0 };
714
+ for (const sotin of newYearsDaySotins) {
715
+ if (sotin.angsar === 0) {
716
+ const minutes = (24 * 60) - (sotin.libda * 24);
717
+ timeOfNewYear = {
718
+ hour: Math.floor(minutes / 60) % 24,
719
+ minute: minutes % 60
720
+ };
721
+ break;
722
+ }
723
+ }
724
+
725
+ // Number of Vanabat days
726
+ const numberOfVanabatDays = (newYearsDaySotins[0].angsar === 0) ? 2 : 1;
727
+
728
+ return {
729
+ timeOfNewYear,
730
+ numberOfVanabatDays,
731
+ newYearsDaySotins
732
+ };
733
+ }
734
+
735
+ // ============================================================================
736
+ // Khmer Date Class
737
+ // ============================================================================
738
+
739
+ class KhmerDate {
740
+ day: number; // 1-15
741
+ moonPhase: MoonPhase; // MoonPhase enum
742
+ monthIndex: MonthIndex;// MonthIndex enum
743
+ beYear: number;
744
+
745
+ constructor(day: number, moonPhase: MoonPhase, monthIndex: MonthIndex, beYear: number) {
746
+ this.day = day;
747
+ this.moonPhase = moonPhase;
748
+ this.monthIndex = monthIndex;
749
+ this.beYear = beYear;
750
+ }
751
+
752
+ // Get day number (0-29) - converts from 1-based internal to 0-based external
753
+ getDayNumber(): number {
754
+ if (this.moonPhase === MoonPhase.Waxing) { // កើត
755
+ return this.day - 1; // day 1-15 → dayNum 0-14
756
+ } else { // រោច
757
+ return 15 + (this.day - 1); // day 1-15 → dayNum 15-29
758
+ }
759
+ }
760
+
761
+ static fromDayNumber(dayNum: number): MoonDayInfo {
762
+ // Converts from 0-based dayNum to 1-based day
763
+ if (dayNum < 15) {
764
+ return { day: dayNum + 1, moonPhase: MoonPhase.Waxing }; // dayNum 0-14 → day 1-15
765
+ } else {
766
+ return { day: (dayNum - 15) + 1, moonPhase: MoonPhase.Waning }; // dayNum 15-29 → day 1-15
767
+ }
768
+ }
769
+
770
+ addDays(count: number): KhmerDate {
771
+ if (count === 0) return this;
772
+ if (count < 0) return this.subtractDays(-count);
773
+
774
+ let result = new KhmerDate(this.day, this.moonPhase, this.monthIndex, this.beYear);
775
+ let remaining = count;
776
+
777
+ while (remaining > 0) {
778
+ const daysInMonth = getNumberOfDaysInKhmerMonth(result.monthIndex, result.beYear);
779
+ const currentDayNum = result.getDayNumber();
780
+ const daysLeftInMonth = (daysInMonth - 1) - currentDayNum;
781
+
782
+ if (remaining <= daysLeftInMonth) {
783
+ const newDayNum = currentDayNum + remaining;
784
+ const newDay = KhmerDate.fromDayNumber(newDayNum);
785
+
786
+ let newBeYear = result.beYear;
787
+ if (result.monthIndex === MonthIndex.Pisakh) { // ពិសាខ
788
+ if (result.moonPhase === MoonPhase.Waxing && newDay.moonPhase === MoonPhase.Waning) {
789
+ newBeYear++;
790
+ }
791
+ }
792
+
793
+ result = new KhmerDate(newDay.day, newDay.moonPhase, result.monthIndex, newBeYear);
794
+ remaining = 0;
795
+ } else {
796
+ remaining -= (daysLeftInMonth + 1);
797
+ const nextMonth = nextMonthOf(result.monthIndex, result.beYear);
798
+ const newBeYear = (result.monthIndex === MonthIndex.Cheit) ? result.beYear + 1 : result.beYear;
799
+ result = new KhmerDate(1, MoonPhase.Waxing, nextMonth, newBeYear); // Start at 1កើត
800
+ }
801
+ }
802
+
803
+ return result;
804
+ }
805
+
806
+ subtractDays(count: number): KhmerDate {
807
+ if (count === 0) return this;
808
+
809
+ let result = new KhmerDate(this.day, this.moonPhase, this.monthIndex, this.beYear);
810
+ let remaining = count;
811
+
812
+ while (remaining > 0) {
813
+ const currentDayNum = result.getDayNumber();
814
+
815
+ if (remaining <= currentDayNum) {
816
+ const newDayNum = currentDayNum - remaining;
817
+ const newDay = KhmerDate.fromDayNumber(newDayNum);
818
+
819
+ let newBeYear = result.beYear;
820
+ if (result.monthIndex === MonthIndex.Pisakh) { // ពិសាខ
821
+ if (result.moonPhase === MoonPhase.Waning && newDay.moonPhase === MoonPhase.Waxing) {
822
+ newBeYear--;
823
+ }
824
+ }
825
+
826
+ result = new KhmerDate(newDay.day, newDay.moonPhase, result.monthIndex, newBeYear);
827
+ remaining = 0;
828
+ } else {
829
+ remaining -= (currentDayNum + 1);
830
+ const prevMonth = previousMonthOf(result.monthIndex, result.beYear);
831
+ const newBeYear = (result.monthIndex === MonthIndex.Pisakh) ? result.beYear - 1 : result.beYear;
832
+ const daysInPrevMonth = getNumberOfDaysInKhmerMonth(prevMonth, newBeYear);
833
+ const newDay = KhmerDate.fromDayNumber(daysInPrevMonth - 1);
834
+ result = new KhmerDate(newDay.day, newDay.moonPhase, prevMonth, newBeYear);
835
+ }
836
+ }
837
+
838
+ return result;
839
+ }
840
+
841
+ toString(): string {
842
+ return `${this.day}${MoonStatusNames[this.moonPhase]} ខែ${LunarMonthNames[this.monthIndex]} ព.ស.${this.beYear}`;
843
+ }
844
+ }
845
+
846
+ // ============================================================================
847
+ // Main Conversion Functions
848
+ // ============================================================================
849
+
850
+ // Helper function to get approximate BE year (like original getMaybeBEYear)
851
+ function getMaybeBEYear(year: number, month: number): number {
852
+ // SolarMonth['មេសា'] = 3 (0-based), so month <= 4 (1-based)
853
+ if (month <= 4) {
854
+ return year + 543;
855
+ } else {
856
+ return year + 544;
857
+ }
858
+ }
859
+
860
+ // Cache for Pisakha Bochea dates by year
861
+ const visakhaBocheaCache: Record<number, number> = {};
862
+
863
+ // Cache for New Year Full Info
864
+ const newYearInfoCache: Record<number, NewYearFullInfo> = {};
865
+
866
+ /**
867
+ * Find BE Year transition datetime for a given Gregorian year
868
+ * BE year increases on ១រោច ខែពិសាខ (1st waning day of Pisakh = dayNumber 15 of month 5)
869
+ * Returns timestamp in milliseconds at midnight of that day
870
+ */
871
+ function getPisakhaBochea(year: number, isSearching: boolean = false): number {
872
+ if (visakhaBocheaCache[year]) {
873
+ return visakhaBocheaCache[year];
874
+ }
875
+
876
+ // Search for 1រោច Pisakh (when BE year changes) - start from April since it typically occurs then
877
+ for (let searchMonth = 4; searchMonth <= 6; searchMonth++) {
878
+ const daysInMonth = new Date(year, searchMonth, 0).getDate();
879
+ for (let searchDay = 1; searchDay <= daysInMonth; searchDay++) {
880
+ // Avoid infinite recursion by using simplified BE year during search
881
+ const result = gregorianToKhmerInternal(year, searchMonth, searchDay, 12, 0, 0, true);
882
+ if (result.khmer.monthIndex === MonthIndex.Pisakh && result._khmerDateObj.getDayNumber() === 15) {
883
+ // Found 1រោច Pisakh - return timestamp at midnight (start of BE year change day)
884
+ // BE year changes at 00:00 on this day
885
+ const timestamp = new Date(year, searchMonth - 1, searchDay, 0, 0, 0, 0).getTime();
886
+ visakhaBocheaCache[year] = timestamp;
887
+ return timestamp;
888
+ }
889
+ }
890
+ }
891
+
892
+ // Fallback if not found
893
+ const fallback = new Date(year, 3, 15, 0, 0, 0, 0).getTime();
894
+ visakhaBocheaCache[year] = fallback;
895
+ return fallback;
896
+ }
897
+
898
+ function gregorianToKhmerInternal(
899
+ year: number,
900
+ month: number,
901
+ day: number,
902
+ hour: number = 0,
903
+ minute: number = 0,
904
+ second: number = 0,
905
+ isSearching: boolean = false
906
+ ): KhmerConversionResult {
907
+ /**
908
+ * This follows the original momentkh algorithm exactly using JDN for tracking
909
+ */
910
+
911
+ // Epoch: January 1, 1900 = dayNumber 0 (១កើត), month index 1 (បុស្ស)
912
+ let epochJdn = gregorianToJulianDay(1900, 1, 1);
913
+ const targetJdn = gregorianToJulianDay(year, month, day);
914
+
915
+ let khmerMonth = 1; // បុស្ស
916
+ let khmerDayNumber = 0; // 0-29 format
917
+
918
+ let diffDays = targetJdn - epochJdn;
919
+
920
+ // Move epoch by full Khmer years
921
+ if (diffDays > 0) {
922
+ while (true) {
923
+ // Get Gregorian date of current epoch to calculate BE year
924
+ const epochGreg = julianDayToGregorian(epochJdn);
925
+ // Match original: use epochMoment.clone().add(1, 'year')
926
+ const nextYearBE = getMaybeBEYear(epochGreg.year + 1, epochGreg.month);
927
+ const daysInNextYear = getNumberOfDaysInKhmerYear(nextYearBE);
928
+
929
+ if (diffDays > daysInNextYear) {
930
+ diffDays -= daysInNextYear;
931
+ epochJdn += daysInNextYear;
932
+ } else {
933
+ break;
934
+ }
935
+ }
936
+ } else if (diffDays < 0) {
937
+ while (diffDays < 0) {
938
+ const epochGreg = julianDayToGregorian(epochJdn);
939
+ const currentYearBE = getMaybeBEYear(epochGreg.year, epochGreg.month);
940
+ const daysInCurrentYear = getNumberOfDaysInKhmerYear(currentYearBE);
941
+ diffDays += daysInCurrentYear;
942
+ epochJdn -= daysInCurrentYear;
943
+ }
944
+ }
945
+
946
+ // Move epoch by full Khmer months
947
+ while (diffDays > 0) {
948
+ const epochGreg = julianDayToGregorian(epochJdn);
949
+ const currentBE = getMaybeBEYear(epochGreg.year, epochGreg.month);
950
+ const daysInMonth = getNumberOfDaysInKhmerMonth(khmerMonth, currentBE);
951
+
952
+ if (diffDays > daysInMonth) {
953
+ diffDays -= daysInMonth;
954
+ epochJdn += daysInMonth;
955
+ khmerMonth = nextMonthOf(khmerMonth, currentBE);
956
+ } else {
957
+ break;
958
+ }
959
+ }
960
+
961
+ // Add remaining days
962
+ khmerDayNumber = diffDays;
963
+
964
+ // Fix overflow (e.g., if month has only 29 days but we calculated 30)
965
+ const finalBE = getMaybeBEYear(year, month);
966
+ const totalDaysInMonth = getNumberOfDaysInKhmerMonth(khmerMonth, finalBE);
967
+ if (khmerDayNumber >= totalDaysInMonth) {
968
+ khmerDayNumber = khmerDayNumber % totalDaysInMonth;
969
+ khmerMonth = nextMonthOf(khmerMonth, finalBE);
970
+ }
971
+
972
+ // Convert dayNumber to day/moonPhase format
973
+ const khmerDayInfo = KhmerDate.fromDayNumber(khmerDayNumber);
974
+
975
+ // Calculate actual BE year
976
+ // The BE year changes on ១រោច ខែពិសាខ (1st waning day of Pisakh = dayNumber 15)
977
+ // Compare datetime (including hour/minute) against BE year transition datetime
978
+ let beYear: number;
979
+ if (isSearching) {
980
+ // During search, use simple approximation to avoid recursion
981
+ beYear = month <= 4 ? year + 543 : year + 544;
982
+ } else {
983
+ // Normal mode: compare against exact BE year transition datetime (1រោច Pisakh at 00:00)
984
+ const inputTimestamp = new Date(year, month - 1, day, hour, minute, second).getTime();
985
+ const beYearTransitionTimestamp = getPisakhaBochea(year);
986
+
987
+ if (inputTimestamp >= beYearTransitionTimestamp) {
988
+ // On or after 1រោច Pisakh (new BE year)
989
+ beYear = year + 544;
990
+ } else {
991
+ // Before 1រោច Pisakh (old BE year)
992
+ beYear = year + 543;
993
+ }
994
+ }
995
+
996
+ // Calculate additional info
997
+ let jsYear = beToJs(beYear);
998
+ let animalYearIndex = ((beYear + 4) % 12 + 12) % 12;
999
+
1000
+ // Adjust Era and Animal Year based on Khmer New Year logic
1001
+ // They should change at New Year, not wait for Pisakha Bochea (which changes BE)
1002
+ if (!isSearching) {
1003
+ const newYearInfo = getNewYearFullInfo(year);
1004
+ const inputTimestamp = new Date(year, month - 1, day, hour, minute, second).getTime();
1005
+ const visakhaBocheaTimestamp = getPisakhaBochea(year);
1006
+
1007
+ // Animal Year changes at Moha Songkran (exact New Year time)
1008
+ // Only apply manual increment if we are in the gap between New Year and Pisakha Bochea
1009
+ // (After Pisakha Bochea, the BE year increments, so the formula based on BE automatically gives the new Animal Year)
1010
+ if (inputTimestamp >= newYearInfo.newYearMoment.getTime() && inputTimestamp <= visakhaBocheaTimestamp) {
1011
+ animalYearIndex = (animalYearIndex + 1) % 12;
1012
+ }
1013
+
1014
+ // Era changes at Midnight of Date Lerng Sak (3rd or 4th day of NY)
1015
+ if (inputTimestamp >= newYearInfo.lerngSakMoment.getTime() && inputTimestamp <= visakhaBocheaTimestamp) {
1016
+ jsYear++;
1017
+ }
1018
+ }
1019
+
1020
+ const eraYearIndex = ((jsYear % 10) + 10) % 10;
1021
+ const dayOfWeek = getDayOfWeek(year, month, day);
1022
+
1023
+ const khmerDate = new KhmerDate(khmerDayInfo.day, khmerDayInfo.moonPhase, khmerMonth as MonthIndex, beYear);
1024
+
1025
+ return {
1026
+ gregorian: { year, month, day, hour, minute, second, dayOfWeek },
1027
+ khmer: {
1028
+ day: khmerDayInfo.day,
1029
+ moonPhase: khmerDayInfo.moonPhase,
1030
+ moonPhaseName: MoonStatusNames[khmerDayInfo.moonPhase],
1031
+ monthIndex: khmerMonth as MonthIndex,
1032
+ monthName: LunarMonthNames[khmerMonth],
1033
+ beYear: beYear,
1034
+ jsYear: jsYear,
1035
+ animalYear: animalYearIndex as AnimalYear,
1036
+ animalYearName: AnimalYearNames[animalYearIndex],
1037
+ eraYear: eraYearIndex as EraYear,
1038
+ eraYearName: EraYearNames[eraYearIndex],
1039
+ dayOfWeek: dayOfWeek as DayOfWeek,
1040
+ dayOfWeekName: WeekdayNames[dayOfWeek]
1041
+ },
1042
+ _khmerDateObj: khmerDate
1043
+ };
1044
+ }
1045
+
1046
+ function khmerToGregorian(day: number, moonPhase: MoonPhase | number, monthIndex: MonthIndex | number, beYear: number): GregorianDate {
1047
+ // Validate input parameters
1048
+ validateKhmerDate(day, moonPhase, monthIndex, beYear);
1049
+
1050
+ // Convert enums to numbers if needed
1051
+ const moonPhaseNum = typeof moonPhase === 'number' ? moonPhase : moonPhase as number;
1052
+ const monthIndexNum = typeof monthIndex === 'number' ? monthIndex : monthIndex as number;
1053
+
1054
+ // Convert BE year to approximate Gregorian year
1055
+ const approxYear = beYear - 544;
1056
+
1057
+ // Search within a range around the approximate year
1058
+ // Start from 2 years before to 2 years after to account for calendar differences
1059
+ const startYear = approxYear - 2;
1060
+ const endYear = approxYear + 2;
1061
+
1062
+ let candidates: GregorianDate[] = [];
1063
+
1064
+ // Iterate through Gregorian dates to find all matches
1065
+ for (let year = startYear; year <= endYear; year++) {
1066
+ for (let month = 1; month <= 12; month++) {
1067
+ const daysInMonth = getDaysInGregorianMonth(year, month);
1068
+ for (let gDay = 1; gDay <= daysInMonth; gDay++) {
1069
+ // For BE year transition day (1រោច Pisakh) and the day before (15កើត Pisakh),
1070
+ // check multiple times during the day because BE year can change during this period
1071
+ const isAroundBEYearChange = monthIndexNum === MonthIndex.Pisakh &&
1072
+ ((day === 15 && moonPhaseNum === MoonPhase.Waxing) || (day === 1 && moonPhaseNum === MoonPhase.Waning));
1073
+ const timesToCheck = isAroundBEYearChange
1074
+ ? [0, 6, 12, 18, 23] // Check at different hours
1075
+ : [0]; // Normal case: just check at midnight
1076
+
1077
+ for (const hour of timesToCheck) {
1078
+ const khmerResult = gregorianToKhmerInternal(year, month, gDay, hour, 0, 0, false);
1079
+
1080
+ // Check if it matches our target
1081
+ if (khmerResult.khmer.beYear === beYear &&
1082
+ khmerResult.khmer.monthIndex === monthIndexNum &&
1083
+ khmerResult.khmer.day === day &&
1084
+ khmerResult.khmer.moonPhase === moonPhaseNum) {
1085
+ candidates.push({ year, month, day: gDay });
1086
+ break; // Found a match for this date, no need to check other times
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ if (candidates.length === 0) {
1094
+ throw new Error(`Could not find Gregorian date for Khmer date: ${day} ${moonPhaseNum === MoonPhase.Waxing ? 'កើត' : 'រោច'} month ${monthIndexNum} BE ${beYear}`);
1095
+ }
1096
+
1097
+ // If multiple candidates found, prefer closest to approximate year
1098
+ if (candidates.length > 1) {
1099
+ // First, try to filter by year distance
1100
+ const minDistance = Math.min(...candidates.map(c => Math.abs(c.year - approxYear)));
1101
+ const closestCandidates = candidates.filter(c => Math.abs(c.year - approxYear) === minDistance);
1102
+
1103
+ // If we have a unique closest candidate, return it
1104
+ if (closestCandidates.length === 1) {
1105
+ return closestCandidates[0];
1106
+ }
1107
+
1108
+ // If there are ties, prefer the one that matches at noon
1109
+ const noonMatches = closestCandidates.filter(c => {
1110
+ const noonCheck = gregorianToKhmerInternal(c.year, c.month, c.day, 12, 0, 0, false);
1111
+ return noonCheck.khmer.beYear === beYear &&
1112
+ noonCheck.khmer.monthIndex === monthIndexNum &&
1113
+ noonCheck.khmer.day === day &&
1114
+ noonCheck.khmer.moonPhase === moonPhaseNum;
1115
+ });
1116
+
1117
+ if (noonMatches.length > 0) {
1118
+ return noonMatches[0];
1119
+ }
1120
+
1121
+ // Fall back to first closest candidate
1122
+ return closestCandidates[0];
1123
+ }
1124
+
1125
+ return candidates[0];
1126
+ }
1127
+
1128
+ function getNewYearFullInfo(ceYear: number): NewYearFullInfo {
1129
+ if (newYearInfoCache[ceYear]) {
1130
+ return newYearInfoCache[ceYear];
1131
+ }
1132
+
1133
+ // Calculate using the standard algorithm first to get necessary info (like angsar for numberNewYearDay)
1134
+ const jsYear = adToJs(ceYear);
1135
+ let newYearInfo = getNewYearInfo(jsYear);
1136
+
1137
+ // Get Lerng Sak info
1138
+ let bodithey = getBoditheyJs(jsYear);
1139
+ const isAthikameasPrev = isAdhikameas(jsYear - 1);
1140
+ const isChantrathimeasPrev = isChantrathimeas(jsYear - 1);
1141
+
1142
+ if (isAthikameasPrev && isChantrathimeasPrev) {
1143
+ bodithey = (bodithey + 1) % 30;
1144
+ }
1145
+
1146
+ // lunar DateLerngSak
1147
+ const lunarDateLerngSak = {
1148
+ day: bodithey >= 6 ? bodithey - 1 : bodithey,
1149
+ month: bodithey >= 6 ? 4 : 5 // ចេត្រ or ពិសាខ
1150
+ };
1151
+
1152
+ // Number of new year days
1153
+ const numberNewYearDay = newYearInfo.newYearsDaySotins[0].angsar === 0 ? 4 : 3;
1154
+
1155
+ // Use April 17 as epoch and work backwards
1156
+ const epochLerngSakGreg = { year: ceYear, month: 4, day: 17 };
1157
+
1158
+ // IMPORTANT: prevent recursion by passing isSearching=true (or any flag that skips Era check)
1159
+ // gregorianToKhmerInternal(..., isSearching=true) uses simplified BE calc and skips Era check
1160
+ const khEpoch = gregorianToKhmerInternal(ceYear, 4, 17, 12, 0, 0, true)._khmerDateObj;
1161
+
1162
+ // Calculate difference
1163
+ const diffFromEpoch = ((khEpoch.monthIndex - 4) * 29 + khEpoch.getDayNumber()) -
1164
+ ((lunarDateLerngSak.month - 4) * 29 + lunarDateLerngSak.day);
1165
+
1166
+ // Calculate days to subtract
1167
+ const daysToSubtract = diffFromEpoch + numberNewYearDay - 1;
1168
+
1169
+ // Calculate new year date (Moha Songkran)
1170
+ const epochJdn = gregorianToJulianDay(epochLerngSakGreg.year, epochLerngSakGreg.month, epochLerngSakGreg.day);
1171
+ let newYearJdn = epochJdn - daysToSubtract;
1172
+
1173
+ // Override with cache if available
1174
+ if (khNewYearMoments[ceYear]) {
1175
+ const [datePart, timePart] = khNewYearMoments[ceYear].split(' ');
1176
+ const [d, m, y] = datePart.split('-').map(Number);
1177
+ const [hr, min] = timePart.split(':').map(Number);
1178
+
1179
+ // Update newYearInfo time
1180
+ newYearInfo.timeOfNewYear = { hour: hr, minute: min };
1181
+
1182
+ // Update JDN based on cached date
1183
+ newYearJdn = gregorianToJulianDay(y, m, d);
1184
+ }
1185
+
1186
+ const newYearDate = julianDayToGregorian(newYearJdn);
1187
+
1188
+ const newYearMoment = new Date(
1189
+ newYearDate.year,
1190
+ newYearDate.month - 1,
1191
+ newYearDate.day,
1192
+ newYearInfo.timeOfNewYear.hour,
1193
+ newYearInfo.timeOfNewYear.minute
1194
+ );
1195
+
1196
+ // Calculate Lerng Sak Date (Midnight)
1197
+ // Lerng Sak is the last day of NY celebration.
1198
+ // Jdn = newYearJdn + (numberNewYearDay - 1)
1199
+ const lerngSakJdn = newYearJdn + numberNewYearDay - 1;
1200
+ const lerngSakDate = julianDayToGregorian(lerngSakJdn);
1201
+ const lerngSakMoment = new Date(lerngSakDate.year, lerngSakDate.month - 1, lerngSakDate.day, 0, 0, 0); // Midnight
1202
+
1203
+ const result: NewYearFullInfo = {
1204
+ newYearMoment,
1205
+ lerngSakMoment,
1206
+ newYearInfo: {
1207
+ year: newYearDate.year,
1208
+ month: newYearDate.month,
1209
+ day: newYearDate.day,
1210
+ hour: newYearInfo.timeOfNewYear.hour,
1211
+ minute: newYearInfo.timeOfNewYear.minute
1212
+ }
1213
+ };
1214
+
1215
+ newYearInfoCache[ceYear] = result;
1216
+ return result;
1217
+ }
1218
+
1219
+ function getKhmerNewYear(ceYear: number): NewYearInfo {
1220
+ const info = getNewYearFullInfo(ceYear);
1221
+ return info.newYearInfo;
1222
+ }
1223
+
1224
+ // ============================================================================
1225
+ // Formatting Functions
1226
+ // ============================================================================
1227
+
1228
+ function formatKhmer(khmerData: KhmerConversionResult, formatString?: string): string {
1229
+ if (!formatString) {
1230
+ // Default format
1231
+ const { khmer } = khmerData;
1232
+ const moonDay = `${khmer.day}${khmer.moonPhaseName}`;
1233
+ return toKhmerNumeral(
1234
+ `ថ្ងៃ${khmer.dayOfWeekName} ${moonDay} ខែ${khmer.monthName} ឆ្នាំ${khmer.animalYearName} ${khmer.eraYearName} ពុទ្ធសករាជ ${khmer.beYear}`
1235
+ );
1236
+ }
1237
+
1238
+ // Custom format
1239
+ const formatRules: Record<string, () => string | number> = {
1240
+ 'W': () => khmerData.khmer.dayOfWeekName,
1241
+ 'w': () => WeekdayNamesShort[khmerData.gregorian.dayOfWeek],
1242
+ 'd': () => khmerData.khmer.day,
1243
+ 'D': () => (khmerData.khmer.day < 10 ? '0' : '') + khmerData.khmer.day,
1244
+ 'n': () => MoonStatusShort[khmerData.khmer.moonPhase],
1245
+ 'N': () => khmerData.khmer.moonPhaseName,
1246
+ 'o': () => MoonDaySymbols[khmerData._khmerDateObj.getDayNumber()],
1247
+ 'm': () => khmerData.khmer.monthName,
1248
+ 'M': () => SolarMonthNames[khmerData.gregorian.month - 1],
1249
+ 'a': () => khmerData.khmer.animalYearName,
1250
+ 'e': () => khmerData.khmer.eraYearName,
1251
+ 'b': () => khmerData.khmer.beYear,
1252
+ 'c': () => khmerData.gregorian.year,
1253
+ 'j': () => khmerData.khmer.jsYear
1254
+ };
1255
+
1256
+ const regex = new RegExp(Object.keys(formatRules).join('|'), 'g');
1257
+ const result = formatString.replace(regex, match => {
1258
+ const value = formatRules[match]();
1259
+ return toKhmerNumeral(String(value));
1260
+ });
1261
+
1262
+ return result;
1263
+ }
1264
+
1265
+ // ============================================================================
1266
+ // Wrapper function for public API
1267
+ function gregorianToKhmer(year: number, month: number, day: number, hour: number = 0, minute: number = 0, second: number = 0): KhmerConversionResult {
1268
+ // Validate input parameters
1269
+ validateGregorianDate(year, month, day, hour, minute, second);
1270
+ return gregorianToKhmerInternal(year, month, day, hour, minute, second, false);
1271
+ }
1272
+
1273
+ // ============================================================================
1274
+ // Public API
1275
+ // ============================================================================
1276
+
1277
+ // Conversion functions
1278
+ export function fromGregorian(year: number, month: number, day: number, hour: number = 0, minute: number = 0, second: number = 0): KhmerConversionResult {
1279
+ return gregorianToKhmer(year, month, day, hour, minute, second);
1280
+ }
1281
+
1282
+ export function fromKhmer(day: number, moonPhase: MoonPhase | number, monthIndex: MonthIndex | number, beYear: number): GregorianDate {
1283
+ return khmerToGregorian(day, moonPhase, monthIndex, beYear);
1284
+ }
1285
+
1286
+ // New Year function
1287
+ export function getNewYear(ceYear: number): NewYearInfo {
1288
+ return getKhmerNewYear(ceYear);
1289
+ }
1290
+
1291
+ // Format function
1292
+ export function format(khmerData: KhmerConversionResult, formatString?: string): string {
1293
+ return formatKhmer(khmerData, formatString);
1294
+ }
1295
+
1296
+ // Utility for creating date from Date object
1297
+ export function fromDate(date: Date): KhmerConversionResult {
1298
+ // Validate Date object
1299
+ validateDateObject(date);
1300
+ return gregorianToKhmer(
1301
+ date.getFullYear(),
1302
+ date.getMonth() + 1,
1303
+ date.getDate(),
1304
+ date.getHours(),
1305
+ date.getMinutes(),
1306
+ date.getSeconds()
1307
+ );
1308
+ }
1309
+
1310
+ // Convert Khmer to Date object
1311
+ export function toDate(day: number, moonPhase: MoonPhase | number, monthIndex: MonthIndex | number, beYear: number): Date {
1312
+ const greg = khmerToGregorian(day, moonPhase, monthIndex, beYear);
1313
+ return new Date(greg.year, greg.month - 1, greg.day);
1314
+ }
1315
+
1316
+ // Constants export
1317
+ export const constants = {
1318
+ LunarMonths,
1319
+ LunarMonthNames,
1320
+ SolarMonthNames,
1321
+ AnimalYearNames,
1322
+ EraYearNames,
1323
+ WeekdayNames,
1324
+ MoonStatusNames
1325
+ };
1326
+
1327
+ // Default export - aggregate all exports for convenience
1328
+ export default {
1329
+ fromGregorian,
1330
+ fromKhmer,
1331
+ getNewYear,
1332
+ format,
1333
+ fromDate,
1334
+ toDate,
1335
+ constants,
1336
+ MoonPhase,
1337
+ MonthIndex,
1338
+ AnimalYear,
1339
+ EraYear,
1340
+ DayOfWeek
1341
+ };