@sanguo-dev/calendar 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/index.cjs +1766 -0
- package/dist/index.d.cts +269 -0
- package/dist/index.d.mts +269 -0
- package/dist/index.iife.js +1769 -0
- package/dist/index.mjs +1760 -0
- package/dist/index.umd.js +1770 -0
- package/package.json +66 -4
- package/src/config.ts +198 -0
- package/src/data/Calendar.ts +599 -0
- package/src/data/festival.json +195 -0
- package/src/data/rest.json +74 -0
- package/src/index.ts +317 -0
- package/src/types.ts +146 -0
- package/src/utils/Assert.ts +230 -0
- package/src/utils/LunarFunction.ts +215 -0
- package/src/utils/SetFestival.ts +40 -0
- package/src/utils/SetRest.ts +84 -0
- package/src/utils/SolarFunction.ts +162 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 公历节日 / 农历节日的映射表
|
|
3
|
+
* key 为四位 "MMDD",value 为节日名称数组
|
|
4
|
+
*/
|
|
5
|
+
export type FestivalMap = Record<string, string[]>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 节日配置,sFtv 为公历节日,lFtv 为农历节日
|
|
9
|
+
*/
|
|
10
|
+
export interface FestivalConfig {
|
|
11
|
+
/** 公历节日,key 为 MMDD,例如 { "0101": ["元旦"] } */
|
|
12
|
+
sFtv?: FestivalMap;
|
|
13
|
+
/** 农历节日,key 为 MMDD,例如 { "0101": ["春节"] } */
|
|
14
|
+
lFtv?: FestivalMap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 单年的放假与调休配置(用户注入时的形态,两个数组都可省略)
|
|
19
|
+
*/
|
|
20
|
+
export interface RestEntry {
|
|
21
|
+
/** 该年放假日(MMDD),不含普通周末 */
|
|
22
|
+
xiu?: string[];
|
|
23
|
+
/** 该年调休补班日(MMDD),一般是周末上班 */
|
|
24
|
+
ban?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 归一化之后的单年配置:两个数组一定存在,内部查询用
|
|
29
|
+
*/
|
|
30
|
+
export interface NormalizedRestEntry {
|
|
31
|
+
xiu: string[];
|
|
32
|
+
ban: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 规范化后的调休表,key 为年份 */
|
|
36
|
+
export type RestMap = Record<string, NormalizedRestEntry>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 调休配置,key 为年份,value 为该年的放假/补班日
|
|
40
|
+
*/
|
|
41
|
+
export type RestConfig = Record<string | number, RestEntry>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* getHolidays() 的返回值:结构完整,可直接再传给 createCalendar()
|
|
45
|
+
*/
|
|
46
|
+
export interface HolidaySnapshot {
|
|
47
|
+
setFestival: {
|
|
48
|
+
sFtv: FestivalMap;
|
|
49
|
+
lFtv: FestivalMap;
|
|
50
|
+
};
|
|
51
|
+
setRest: Record<string, NormalizedRestEntry>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* createCalendar 的入参
|
|
56
|
+
*/
|
|
57
|
+
export interface HolidayConfig {
|
|
58
|
+
setFestival?: FestivalConfig;
|
|
59
|
+
setRest?: RestConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* createCalendar 的选项
|
|
64
|
+
*/
|
|
65
|
+
export interface SetHolidayOptions {
|
|
66
|
+
/** 为 true 时丢弃内置节日/调休数据,只使用传入的数据 */
|
|
67
|
+
replace?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 公历转农历 / 农历转公历的完整结果
|
|
72
|
+
*/
|
|
73
|
+
export interface LunarResult {
|
|
74
|
+
/** 是否在 rest.json 的放假日列表(xiu)中;与周末无关 */
|
|
75
|
+
xiu: boolean;
|
|
76
|
+
/** 是否在 rest.json 的调休补班日列表(ban)中 */
|
|
77
|
+
ban: boolean;
|
|
78
|
+
/** 公历节日,例如 ["元旦"];没有节日时是共享的只读空数组 */
|
|
79
|
+
gljr: readonly string[];
|
|
80
|
+
/** 农历节日,例如 ["春节"];没有节日时是共享的只读空数组 */
|
|
81
|
+
nljr: readonly string[];
|
|
82
|
+
/** 农历年 */
|
|
83
|
+
lYear: number;
|
|
84
|
+
/** 农历月,1-12 */
|
|
85
|
+
lMonth: number;
|
|
86
|
+
/** 农历日,1-30 */
|
|
87
|
+
lDay: number;
|
|
88
|
+
/** 生肖,以农历年为准 */
|
|
89
|
+
animal: string;
|
|
90
|
+
/** 农历月中文名,例如 "正月"、"闰四月" */
|
|
91
|
+
lMonthCn: string;
|
|
92
|
+
/** 农历日中文名,例如 "初一" */
|
|
93
|
+
lDayCn: string;
|
|
94
|
+
/** 公历年 */
|
|
95
|
+
cYear: number;
|
|
96
|
+
/** 公历月,1-12 */
|
|
97
|
+
cMonth: number;
|
|
98
|
+
/** 公历日 */
|
|
99
|
+
cDay: number;
|
|
100
|
+
/** 干支年,以立春为界,与 gzMonth 口径一致 */
|
|
101
|
+
gzYear: string;
|
|
102
|
+
/** 干支年,以正月初一为界,与 animal 口径一致 */
|
|
103
|
+
gzYearLunar: string;
|
|
104
|
+
/** 干支月,以节气为界 */
|
|
105
|
+
gzMonth: string;
|
|
106
|
+
/** 干支日 */
|
|
107
|
+
gzDay: string;
|
|
108
|
+
/** 当天十二个时辰的干支 */
|
|
109
|
+
gzHour: string[];
|
|
110
|
+
/** 是否今天(按本地时区) */
|
|
111
|
+
isToday: boolean;
|
|
112
|
+
/** 是否闰月 */
|
|
113
|
+
isLeap: boolean;
|
|
114
|
+
/** 星期数字,1-7,周一为 1、周日为 7 */
|
|
115
|
+
nWeek: number;
|
|
116
|
+
/** 星期数字,0-6,周日为 0,与 solarFirstWeek 口径一致 */
|
|
117
|
+
dayOfWeek: number;
|
|
118
|
+
/** 星期中文,例如 "星期日" */
|
|
119
|
+
ncWeek: string;
|
|
120
|
+
/** 节气名,当天不是节气时为 null */
|
|
121
|
+
term: string | null;
|
|
122
|
+
/** 星座,例如 "摩羯座" */
|
|
123
|
+
astro: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* getHolidays 的选项
|
|
128
|
+
*/
|
|
129
|
+
export interface GetHolidaysOptions {
|
|
130
|
+
/** 默认 true:返回合并内置数据后的完整配置;false:只返回创建实例时传入的那部分 */
|
|
131
|
+
merged?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 固定了一份节假日配置的日历实例
|
|
136
|
+
*
|
|
137
|
+
* 由 createCalendar() 创建,配置在创建时固定,实例之间互不影响。
|
|
138
|
+
*/
|
|
139
|
+
export interface Calendar {
|
|
140
|
+
/** 公历转农历,不传参返回今天 */
|
|
141
|
+
solarToLunar(this: void, y?: number, m?: number, d?: number): LunarResult;
|
|
142
|
+
/** 农历转公历 */
|
|
143
|
+
lunarToSolar(this: void, y: number, m: number, d: number, isLeapMonth?: boolean): LunarResult;
|
|
144
|
+
/** 该实例当前配置的深拷贝,可再次用于 createCalendar() */
|
|
145
|
+
getHolidays(this: void, options?: GetHolidaysOptions): HolidaySnapshot;
|
|
146
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一的参数错误、类型守卫,以及节日 / 调休配置的校验与规范化
|
|
3
|
+
*/
|
|
4
|
+
import type { FestivalMap, RestMap } from "../types";
|
|
5
|
+
|
|
6
|
+
/** 四位 "MMDD",月份 01-12、日期 01-31 */
|
|
7
|
+
const MMDD_RE = /^(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/;
|
|
8
|
+
|
|
9
|
+
/** 预生成的 MMDD 查表键(12 × 31),避免每次查节日 / 调休都拼字符串 */
|
|
10
|
+
const MMDD: string[][] = Array.from({ length: 13 }, () => Array.from({ length: 32 }, () => ""));
|
|
11
|
+
|
|
12
|
+
for (let m = 1; m <= 12; m++) {
|
|
13
|
+
for (let d = 1; d <= 31; d++) {
|
|
14
|
+
(MMDD[m] as string[])[d] = (m < 10 ? "0" + m : String(m)) + (d < 10 ? "0" + d : String(d));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 取四位 MMDD 字符串,例如 mmddOf(1, 1) → "0101" */
|
|
19
|
+
export const mmddOf = function (m: number, d: number): string {
|
|
20
|
+
return (MMDD[m] as string[])[d] as string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 调休年份:1900-2100 */
|
|
24
|
+
const YEAR_RE = /^(19\d{2}|20\d{2}|2100)$/;
|
|
25
|
+
|
|
26
|
+
/** 公历每个月的最大天数,2 月按闰年放宽到 29 */
|
|
27
|
+
const SOLAR_MAX_DAY = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
28
|
+
|
|
29
|
+
/** 调休表每年的字段 */
|
|
30
|
+
const REST_FIELDS = ["xiu", "ban"];
|
|
31
|
+
|
|
32
|
+
/** 抛出带位置信息的 TypeError,用于配置格式错误 */
|
|
33
|
+
export function failType(path: string, message: string): never {
|
|
34
|
+
throw new TypeError(`[calendar] ${path} ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 抛出带位置信息的 RangeError,用于数值越界 */
|
|
38
|
+
export function failRange(path: string, message: string): never {
|
|
39
|
+
throw new RangeError(`[calendar] ${path} ${message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 是否为普通对象(非 null、非数组) */
|
|
43
|
+
export const isPlainObject = function (value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 校验是普通对象,否则按 path 抛 TypeError;断言之后可以直接当字典用 */
|
|
48
|
+
export function assertPlainObject(
|
|
49
|
+
value: unknown,
|
|
50
|
+
path: string,
|
|
51
|
+
message = "必须是对象",
|
|
52
|
+
): asserts value is Record<string, unknown> {
|
|
53
|
+
if (!isPlainObject(value)) failType(path, `${message},收到 ${describe(value)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 校验对象里没有白名单之外的字段 */
|
|
57
|
+
export const assertKnownKeys = function (
|
|
58
|
+
value: object,
|
|
59
|
+
allowed: readonly string[],
|
|
60
|
+
path: string,
|
|
61
|
+
): void {
|
|
62
|
+
Object.keys(value).forEach(function (key) {
|
|
63
|
+
if (allowed.indexOf(key) === -1) {
|
|
64
|
+
failType(`${path}.${key}`, `不认识的字段,只支持 ${allowed.join(" 与 ")}`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** 把任意值格式化成便于排查的短字符串 */
|
|
70
|
+
export const describe = function (value: unknown): string {
|
|
71
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
72
|
+
if (
|
|
73
|
+
typeof value === "number" ||
|
|
74
|
+
typeof value === "boolean" ||
|
|
75
|
+
value === null ||
|
|
76
|
+
value === undefined
|
|
77
|
+
) {
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
81
|
+
if (typeof value === "object") {
|
|
82
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
83
|
+
return `Object(keys: ${keys.slice(0, 5).join(", ")}${keys.length > 5 ? ", …" : ""})`;
|
|
84
|
+
}
|
|
85
|
+
return typeof value;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** 校验一个整数并返回它 */
|
|
89
|
+
export const assertInteger = function (
|
|
90
|
+
path: string,
|
|
91
|
+
value: unknown,
|
|
92
|
+
min: number,
|
|
93
|
+
max: number,
|
|
94
|
+
): number {
|
|
95
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
96
|
+
return failRange(path, `必须是整数,收到 ${describe(value)}`);
|
|
97
|
+
}
|
|
98
|
+
if (value < min || value > max) {
|
|
99
|
+
return failRange(path, `必须在 ${min}-${max} 之间,收到 ${value}`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** 公历 MMDD 是否对应真实存在的日期(例如 0231、0431 为 false) */
|
|
105
|
+
const isSolarMonthDay = function (m: number, d: number): boolean {
|
|
106
|
+
return d >= 1 && d <= (SOLAR_MAX_DAY[m - 1] ?? 0);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** 农历 MMDD:每个月最多 30 天 */
|
|
110
|
+
const isLunarMonthDay = function (m: number, d: number): boolean {
|
|
111
|
+
return d >= 1 && d <= 30;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 校验并解析四位 MMDD:节日 key 与调休日期共用同一套格式和日期检查
|
|
116
|
+
*
|
|
117
|
+
* @param value 待校验值,必须是 "MMDD" 字符串
|
|
118
|
+
* @param path 出错时用于定位的路径
|
|
119
|
+
* @param options.lunar 按农历校验(每月最多 30 天),默认公历
|
|
120
|
+
* @param options.label 报错信息里的主语,例如 "节日 key"
|
|
121
|
+
*/
|
|
122
|
+
export function assertMMDD(
|
|
123
|
+
value: unknown,
|
|
124
|
+
path: string,
|
|
125
|
+
options: { lunar?: boolean; label?: string } = {},
|
|
126
|
+
): { month: number; day: number; text: string } {
|
|
127
|
+
const { lunar = false, label } = options;
|
|
128
|
+
const prefix = label === undefined ? "" : `${label} `;
|
|
129
|
+
const parsed =
|
|
130
|
+
typeof value === "string" && MMDD_RE.test(value)
|
|
131
|
+
? { month: Number(value.slice(0, 2)), day: Number(value.slice(2)), text: value }
|
|
132
|
+
: null;
|
|
133
|
+
|
|
134
|
+
if (parsed === null) {
|
|
135
|
+
failType(path, `${prefix}必须是四位 "MMDD"(如 "0101"),收到 ${describe(value)}`);
|
|
136
|
+
}
|
|
137
|
+
const valid = lunar
|
|
138
|
+
? isLunarMonthDay(parsed.month, parsed.day)
|
|
139
|
+
: isSolarMonthDay(parsed.month, parsed.day);
|
|
140
|
+
if (!valid) {
|
|
141
|
+
failType(path, `${lunar ? "农历" : "公历"}不存在 ${parsed.month} 月 ${parsed.day} 日`);
|
|
142
|
+
}
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 校验并规范化节日表
|
|
148
|
+
*
|
|
149
|
+
* @param input 待校验的节日表
|
|
150
|
+
* @param path 出错时用于定位的路径,例如 "createCalendar.setFestival.sFtv"
|
|
151
|
+
* @param lunar 农历节日(true)还是公历节日(false)
|
|
152
|
+
*/
|
|
153
|
+
const normalizeFestivalMap = function (input: unknown, path: string, lunar = false): FestivalMap {
|
|
154
|
+
assertPlainObject(input, path);
|
|
155
|
+
const result: FestivalMap = {};
|
|
156
|
+
Object.keys(input).forEach(function (key) {
|
|
157
|
+
assertMMDD(key, `${path}.${key}`, { lunar, label: "节日 key" });
|
|
158
|
+
const value = input[key];
|
|
159
|
+
if (!Array.isArray(value)) {
|
|
160
|
+
throw failType(`${path}.${key}`, `节日名称必须是字符串数组,收到 ${describe(value)}`);
|
|
161
|
+
}
|
|
162
|
+
const names: string[] = [];
|
|
163
|
+
value.forEach(function (name: unknown, index: number) {
|
|
164
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
165
|
+
throw failType(
|
|
166
|
+
`${path}.${key}[${index}]`,
|
|
167
|
+
`节日名称必须是非空字符串,收到 ${describe(name)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
names.push(name);
|
|
171
|
+
});
|
|
172
|
+
result[key] = names;
|
|
173
|
+
});
|
|
174
|
+
return result;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** 校验并去重一组调休日期(MMDD 字符串) */
|
|
178
|
+
const normalizeDayList = function (input: unknown, path: string): string[] {
|
|
179
|
+
if (!Array.isArray(input)) {
|
|
180
|
+
throw failType(path, `必须是 "MMDD" 字符串数组,收到 ${describe(input)}`);
|
|
181
|
+
}
|
|
182
|
+
const days: string[] = [];
|
|
183
|
+
input.forEach(function (day: unknown, index: number) {
|
|
184
|
+
days.push(assertMMDD(day, `${path}[${index}]`).text);
|
|
185
|
+
});
|
|
186
|
+
return Array.from(new Set(days));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 校验并规范化调休表,key 为年份
|
|
191
|
+
*
|
|
192
|
+
* @param input 待校验的调休表
|
|
193
|
+
* @param path 出错时用于定位的路径
|
|
194
|
+
*/
|
|
195
|
+
const normalizeRestMap = function (input: unknown, path: string): RestMap {
|
|
196
|
+
assertPlainObject(input, path);
|
|
197
|
+
const result: RestMap = {};
|
|
198
|
+
Object.keys(input).forEach(function (yearKey) {
|
|
199
|
+
if (!YEAR_RE.test(yearKey)) {
|
|
200
|
+
throw failType(
|
|
201
|
+
`${path}.${yearKey}`,
|
|
202
|
+
`年份必须是 1900-2100 的四位数字,收到 ${describe(yearKey)}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const entry = input[yearKey];
|
|
206
|
+
assertPlainObject(entry, `${path}.${yearKey}`, "必须是 { xiu: [], ban: [] }");
|
|
207
|
+
assertKnownKeys(entry, REST_FIELDS, `${path}.${yearKey}`);
|
|
208
|
+
|
|
209
|
+
const xiu =
|
|
210
|
+
entry.xiu === undefined ? [] : normalizeDayList(entry.xiu, `${path}.${yearKey}.xiu`);
|
|
211
|
+
const ban =
|
|
212
|
+
entry.ban === undefined ? [] : normalizeDayList(entry.ban, `${path}.${yearKey}.ban`);
|
|
213
|
+
|
|
214
|
+
// 放假和补班来自同一份法定节假日安排,同一天不可能既放假又补班
|
|
215
|
+
const conflict = xiu.filter(function (day) {
|
|
216
|
+
return ban.indexOf(day) !== -1;
|
|
217
|
+
});
|
|
218
|
+
if (conflict.length > 0) {
|
|
219
|
+
throw failType(
|
|
220
|
+
`${path}.${yearKey}`,
|
|
221
|
+
`同一天不能既是放假日又是补班日:${conflict.join("、")}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
result[yearKey] = { xiu, ban };
|
|
226
|
+
});
|
|
227
|
+
return result;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export { normalizeFestivalMap, normalizeRestMap };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { LunarInfo, Gan, Zhi, GanZhiHour, SolarTerm, nStr3, nStr2, nStr1 } from "../data/Calendar";
|
|
2
|
+
import { assertInteger, failRange } from "./Assert";
|
|
3
|
+
|
|
4
|
+
/** 农历数据表覆盖的年份 */
|
|
5
|
+
const LUNAR_YEAR_MIN = 1900;
|
|
6
|
+
const LUNAR_YEAR_MAX = 2100;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 校验农历年份是否在数据表范围内,越界直接抛 RangeError
|
|
10
|
+
*/
|
|
11
|
+
const assertLunarYear = function (y: number, path: string): number {
|
|
12
|
+
return assertInteger(path, y, LUNAR_YEAR_MIN, LUNAR_YEAR_MAX);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 返回农历 y 年一整年的总天数
|
|
17
|
+
* @param y 农历年(1900-2100)
|
|
18
|
+
* @return 348-385
|
|
19
|
+
*/
|
|
20
|
+
const lYearDays = function (y: number): number {
|
|
21
|
+
assertLunarYear(y, "lYearDays(year)");
|
|
22
|
+
let sum = 348;
|
|
23
|
+
for (let i = 0x8000; i > 0x8; i >>= 1) {
|
|
24
|
+
sum += LunarInfo[y - 1900] & i ? 1 : 0;
|
|
25
|
+
}
|
|
26
|
+
return sum + leapDays(y);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 返回农历 y 年闰月是哪个月;没有闰月返回 0
|
|
31
|
+
* @param y 农历年(1900-2100)
|
|
32
|
+
*/
|
|
33
|
+
const leapMonth = function (y: number): number {
|
|
34
|
+
assertLunarYear(y, "leapMonth(year)");
|
|
35
|
+
return LunarInfo[y - 1900] & 0xf;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 返回农历 y 年闰月的天数;没有闰月返回 0
|
|
40
|
+
* @param y 农历年(1900-2100)
|
|
41
|
+
*/
|
|
42
|
+
const leapDays = function (y: number): number {
|
|
43
|
+
if (leapMonth(y)) {
|
|
44
|
+
return LunarInfo[y - 1900] & 0x10000 ? 30 : 29;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 返回农历 y 年 m 月(非闰月)的天数,闰月天数请用 leapDays
|
|
51
|
+
* @param y 农历年(1900-2100)
|
|
52
|
+
* @param m 农历月(1-12)
|
|
53
|
+
*/
|
|
54
|
+
const lMonthDays = function (y: number, m: number): number {
|
|
55
|
+
assertLunarYear(y, "lMonthDays(year)");
|
|
56
|
+
assertInteger("lMonthDays(month)", m, 1, 12);
|
|
57
|
+
return LunarInfo[y - 1900] & (0x10000 >> m) ? 30 : 29;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** 农历每年正月初一距 1900-01-31 的天数;长度 202,最后一项是 2101 正月初一 */
|
|
61
|
+
const YEAR_START: number[] = (function () {
|
|
62
|
+
const starts: number[] = [0];
|
|
63
|
+
for (let y = LUNAR_YEAR_MIN; y <= LUNAR_YEAR_MAX; y++) {
|
|
64
|
+
starts.push((starts[starts.length - 1] as number) + lYearDays(y));
|
|
65
|
+
}
|
|
66
|
+
return starts;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
/** 某年正月初一距 1900-01-31 的天数 */
|
|
70
|
+
const yearStartOf = function (year: number): number {
|
|
71
|
+
assertLunarYear(year, "yearStartOf(year)");
|
|
72
|
+
return YEAR_START[year - LUNAR_YEAR_MIN] as number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** 农历某年的一个“月份段”,闰月算独立一段 */
|
|
76
|
+
export interface LunarMonthSegment {
|
|
77
|
+
month: number;
|
|
78
|
+
isLeap: boolean;
|
|
79
|
+
/** 该段 1 号距当年正月初一的天数 */
|
|
80
|
+
start: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const segmentCache = new Map<number, LunarMonthSegment[]>();
|
|
84
|
+
|
|
85
|
+
/** 某年的月份段表(按时间顺序),第一次构建后缓存 */
|
|
86
|
+
const monthSegmentsOf = function (year: number): LunarMonthSegment[] {
|
|
87
|
+
const cached = segmentCache.get(year);
|
|
88
|
+
if (cached) return cached;
|
|
89
|
+
|
|
90
|
+
const leap = leapMonth(year);
|
|
91
|
+
const segments: LunarMonthSegment[] = [];
|
|
92
|
+
let start = 0;
|
|
93
|
+
|
|
94
|
+
for (let m = 1; m <= 12; m++) {
|
|
95
|
+
segments.push({ month: m, isLeap: false, start });
|
|
96
|
+
start += lMonthDays(year, m);
|
|
97
|
+
if (leap === m) {
|
|
98
|
+
segments.push({ month: m, isLeap: true, start });
|
|
99
|
+
start += leapDays(year);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
segmentCache.set(year, segments);
|
|
104
|
+
return segments;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 把“距 1900-01-31 的天数”换算成农历年与当年第几天(二分查找,替代逐年累减) */
|
|
108
|
+
const resolveLunarYear = function (offset: number): { year: number; offsetInYear: number } {
|
|
109
|
+
let low = 0;
|
|
110
|
+
let high = LUNAR_YEAR_MAX - LUNAR_YEAR_MIN;
|
|
111
|
+
|
|
112
|
+
while (low < high) {
|
|
113
|
+
const mid = (low + high + 1) >>> 1;
|
|
114
|
+
if ((YEAR_START[mid] as number) <= offset) low = mid;
|
|
115
|
+
else high = mid - 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
year: LUNAR_YEAR_MIN + low,
|
|
120
|
+
offsetInYear: offset - (YEAR_START[low] as number),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 农历年转换为干支纪年
|
|
126
|
+
* @param lYear 农历年,允许 1899 这类边界年(只做算术,不查表)
|
|
127
|
+
*/
|
|
128
|
+
const toGanZhiYear = function (lYear: number): string {
|
|
129
|
+
if (typeof lYear !== "number" || !Number.isInteger(lYear)) {
|
|
130
|
+
failRange("toGanZhiYear(year)", `必须是整数,收到 ${lYear}`);
|
|
131
|
+
}
|
|
132
|
+
let ganKey = (lYear - 3) % 10;
|
|
133
|
+
let zhiKey = (lYear - 3) % 12;
|
|
134
|
+
if (ganKey <= 0) ganKey += 10;
|
|
135
|
+
if (zhiKey <= 0) zhiKey += 12;
|
|
136
|
+
return Gan[ganKey - 1] + Zhi[zhiKey - 1];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 传入相对甲子的偏移量返回干支
|
|
141
|
+
* @param offset 可以为负数
|
|
142
|
+
*/
|
|
143
|
+
const toGanZhi = function (offset: number): string {
|
|
144
|
+
if (typeof offset !== "number" || !Number.isInteger(offset)) {
|
|
145
|
+
failRange("toGanZhi(offset)", `必须是整数,收到 ${offset}`);
|
|
146
|
+
}
|
|
147
|
+
const gan = ((offset % 10) + 10) % 10;
|
|
148
|
+
const zhi = ((offset % 12) + 12) % 12;
|
|
149
|
+
return Gan[gan] + Zhi[zhi];
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 传入当天的干支偏移量,返回当天十二个时辰的干支
|
|
154
|
+
* @param offset 相对甲子的偏移量(与 gzDay 用同一个偏移量)
|
|
155
|
+
*/
|
|
156
|
+
const toHourGanZhi = function (offset: number): string[] {
|
|
157
|
+
if (typeof offset !== "number" || !Number.isInteger(offset)) {
|
|
158
|
+
failRange("toHourGanZhi(offset)", `必须是整数,收到 ${offset}`);
|
|
159
|
+
}
|
|
160
|
+
const index = ((offset % 10) + 10) % 10;
|
|
161
|
+
return GanZhiHour[index].split("-");
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 农历数字月份转中文,例如 1 -> "正月"、12 -> "腊月"
|
|
166
|
+
* @param m 农历月(1-12)
|
|
167
|
+
*/
|
|
168
|
+
const toChinaMonth = function (m: number): string {
|
|
169
|
+
assertInteger("toChinaMonth(month)", m, 1, 12);
|
|
170
|
+
return nStr3[m - 1] + "月";
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 农历日期数字转中文,例如 1 -> "初一"、21 -> "廿一"
|
|
175
|
+
* @param d 农历日(1-30)
|
|
176
|
+
*/
|
|
177
|
+
const toChinaDay = function (d: number): string {
|
|
178
|
+
assertInteger("toChinaDay(day)", d, 1, 30);
|
|
179
|
+
switch (d) {
|
|
180
|
+
case 10:
|
|
181
|
+
return "\u521d\u5341";
|
|
182
|
+
case 20:
|
|
183
|
+
return "\u4e8c\u5341";
|
|
184
|
+
case 30:
|
|
185
|
+
return "\u4e09\u5341";
|
|
186
|
+
default:
|
|
187
|
+
return nStr2[Math.floor(d / 10)] + nStr1[d % 10];
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 取第 n 个节气名(n 从 0 开始,0 为小寒)
|
|
193
|
+
* @param n 0-23
|
|
194
|
+
*/
|
|
195
|
+
const getSolarTerm = function (n: number): string {
|
|
196
|
+
assertInteger("getSolarTerm(n)", n, 0, 23);
|
|
197
|
+
return SolarTerm[n];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export {
|
|
201
|
+
lYearDays,
|
|
202
|
+
leapMonth,
|
|
203
|
+
leapDays,
|
|
204
|
+
lMonthDays,
|
|
205
|
+
yearStartOf,
|
|
206
|
+
monthSegmentsOf,
|
|
207
|
+
resolveLunarYear,
|
|
208
|
+
toGanZhiYear,
|
|
209
|
+
toGanZhi,
|
|
210
|
+
toHourGanZhi,
|
|
211
|
+
toChinaMonth,
|
|
212
|
+
toChinaDay,
|
|
213
|
+
getSolarTerm,
|
|
214
|
+
assertLunarYear,
|
|
215
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mmddOf } from "./Assert";
|
|
2
|
+
import type { FestivalMap } from "../types";
|
|
3
|
+
|
|
4
|
+
/** 没有节日时共用的空数组,冻结后不怕调用方误改 */
|
|
5
|
+
const NO_FESTIVAL: readonly string[] = Object.freeze([]) as readonly string[];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 把两份节日表按 key 合并并去重,不修改入参
|
|
9
|
+
*/
|
|
10
|
+
const mergeFestivalMap = function (base: FestivalMap, extra?: FestivalMap): FestivalMap {
|
|
11
|
+
if (!extra) return base;
|
|
12
|
+
const result: FestivalMap = { ...base };
|
|
13
|
+
Object.keys(extra).forEach(function (key) {
|
|
14
|
+
const merged = result[key] ? result[key].concat(extra[key]) : extra[key];
|
|
15
|
+
result[key] = Array.from(new Set(merged));
|
|
16
|
+
});
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 冻结节日表,防止调用方后续误改
|
|
22
|
+
*/
|
|
23
|
+
const freezeFestivalMap = function (map: FestivalMap): FestivalMap {
|
|
24
|
+
Object.keys(map).forEach(function (key) {
|
|
25
|
+
Object.freeze(map[key]);
|
|
26
|
+
});
|
|
27
|
+
return Object.freeze(map);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 取某一天(MMDD)的节日名称,没有则返回共享的空数组。
|
|
32
|
+
*
|
|
33
|
+
* 返回的是只读数组:配置里的数组已被冻结,调用方要追加节日必须自己 copy。
|
|
34
|
+
*/
|
|
35
|
+
const festivalList = function (map: FestivalMap, m: number, d: number): readonly string[] {
|
|
36
|
+
return map[mmddOf(m, d)] ?? NO_FESTIVAL;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { mergeFestivalMap, freezeFestivalMap, festivalList, NO_FESTIVAL };
|
|
40
|
+
export type { FestivalMap };
|