dsh-metaphysics 0.13.6 → 0.14.2

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/engine/bazi.js CHANGED
@@ -39,6 +39,12 @@ const OVERCOMES = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' }
39
39
 
40
40
  const isYangStem = (stem) => STEMS.indexOf(stem) % 2 === 0;
41
41
 
42
+ // 两套藏干权重是不同用途,不是"同一件事写了两次":
43
+ // · 五行占比——看这一行在盘里有多少,本气 1 / 中气 0.5 / 余气 0.3;
44
+ // · 日主通根——看这一支能不能让日主站住,本气权重更高(1.2 / 0.6 / 0.3)。
45
+ const WUXING_HIDDEN_WEIGHT = [1, 0.5, 0.3];
46
+ const ROOT_HIDDEN_WEIGHT = [1.2, 0.6, 0.3];
47
+
42
48
  const STEM_HE = { 甲己: '土', 乙庚: '金', 丙辛: '水', 丁壬: '木', 戊癸: '火' };
43
49
  const STEM_CHONG_PAIRS = ['甲庚', '乙辛', '丙壬', '丁癸'];
44
50
  const BRANCH_HE = { 子丑: '土', 寅亥: '木', 卯戌: '火', 辰酉: '金', 巳申: '水', 午未: '土' };
@@ -47,7 +53,10 @@ const SANHUI = { 寅卯辰: '木', 巳午未: '火', 申酉戌: '金', 亥子丑
47
53
  const BRANCH_CHONG = ['子午', '丑未', '寅申', '卯酉', '辰戌', '巳亥'];
48
54
  const BRANCH_HAI = ['子未', '丑午', '寅巳', '卯辰', '申亥', '酉戌'];
49
55
  const BRANCH_PO = ['子酉', '丑辰', '寅亥', '卯午', '巳申', '未戌'];
56
+ // 三刑:寅巳申(无恩之刑)、丑戌未(恃势之刑)、子卯(无礼之刑)。
57
+ // 另有「自刑」:辰午酉亥四支见同支即自刑(辰辰、午午、酉酉、亥亥),此前漏判。
50
58
  const XING_GROUPS = [['寅', '巳', '申'], ['丑', '戌', '未'], ['子', '卯']];
59
+ const XING_SELF = ['辰', '午', '酉', '亥'];
51
60
 
52
61
  const hasPair = (list, a, b) => list.includes(a + b) || list.includes(b + a);
53
62
 
@@ -92,7 +101,11 @@ export function buildRelations(pillars) {
92
101
  for (let j = i + 1; j < 4; j++) {
93
102
  const a = branches[i];
94
103
  const b = branches[j];
95
- if (a === b) continue;
104
+ if (a === b) {
105
+ // 同支不是"没关系":辰午酉亥两柱同见为自刑
106
+ if (XING_SELF.includes(a)) branchOut.push({ text: `${a}${b}自刑`, rank: 2 });
107
+ continue;
108
+ }
96
109
  const he = BRANCH_HE[a + b] || BRANCH_HE[b + a];
97
110
  const sh = Object.keys(SANHE).find((k) => k.includes(a) && k.includes(b));
98
111
  const hui = Object.keys(SANHUI).find((k) => k.includes(a) && k.includes(b));
@@ -258,8 +271,10 @@ export function buildBazi(input) {
258
271
  const forward = (isYangYear && gender === 'male') || (!isYangYear && gender === 'female');
259
272
  const spanDays = forward ? nextJieTerm.jd - jd : jd - prevJieTerm.jd;
260
273
  const totalMonths = spanDays * 4; // 3 天 = 1 年 = 12 个月
261
- const startAgeYears = Math.floor(totalMonths / 12);
262
- const startAgeMonths = Math.round(totalMonths % 12);
274
+ let startAgeYears = Math.floor(totalMonths / 12);
275
+ let startAgeMonths = Math.round(totalMonths % 12);
276
+ // 四舍五入到 12 个月时必须进位,否则显示「X 岁 12 个月起运」
277
+ if (startAgeMonths >= 12) { startAgeMonths -= 12; startAgeYears += 1; }
263
278
 
264
279
  const dayun = [];
265
280
  for (let i = 1; i <= 8; i++) {
@@ -276,8 +291,8 @@ export function buildBazi(input) {
276
291
  });
277
292
  }
278
293
 
279
- // 流年(当年起 10 年)
280
- const thisYear = new Date().getFullYear();
294
+ // 流年(默认从「今年」起排 10 年;传 input.nowYear 可钉死基准年,用于复现历史结论)
295
+ const thisYear = Number(input.nowYear) || new Date().getFullYear();
281
296
  const liunian = [];
282
297
  for (let y = thisYear; y < thisYear + 10; y++) {
283
298
  const idx = (((y - 4) % 60) + 60) % 60;
@@ -289,13 +304,12 @@ export function buildBazi(input) {
289
304
  });
290
305
  }
291
306
 
292
- // 五行统计(天干 1.0;地支藏干按 本气1.0/中气0.5/余气0.3)
293
- const weights = [1, 0.5, 0.3];
307
+ // 五行统计(天干 1.0;地支藏干按 WUXING_HIDDEN_WEIGHT)
294
308
  const wuxing = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 };
295
309
  for (const p of Object.values(pillars)) {
296
310
  wuxing[p.stemElement] += 1;
297
311
  p.hidden.forEach((h, i) => {
298
- wuxing[h.element] += weights[i] ?? 0.1;
312
+ wuxing[h.element] += WUXING_HIDDEN_WEIGHT[i] ?? 0.1;
299
313
  });
300
314
  }
301
315
  for (const k of Object.keys(wuxing)) wuxing[k] = Math.round(wuxing[k] * 10) / 10;
@@ -352,15 +366,14 @@ export function buildBazi(input) {
352
366
  else if (OVERCOMES[me] === seasonEl) { monthW = 0.5; monthWord = '囚'; }
353
367
  else { monthW = 0; monthWord = '死'; }
354
368
 
355
- const HID_W = [1.2, 0.6, 0.3];
356
369
  let rootScore = 0;
357
370
  const rootBranches = [];
358
371
  for (const key of ['year', 'month', 'day', 'hour']) {
359
372
  const pl = pillars[key];
360
373
  let hit = false;
361
374
  pl.hidden.forEach((h, i) => {
362
- if (STEM_ELEMENT[h.stem] === me) { rootScore += HID_W[i] ?? 0.1; hit = true; }
363
- else if (GENERATES[STEM_ELEMENT[h.stem]] === me) { rootScore += (HID_W[i] ?? 0.1) * 0.4; hit = true; }
375
+ if (STEM_ELEMENT[h.stem] === me) { rootScore += ROOT_HIDDEN_WEIGHT[i] ?? 0.1; hit = true; }
376
+ else if (GENERATES[STEM_ELEMENT[h.stem]] === me) { rootScore += (ROOT_HIDDEN_WEIGHT[i] ?? 0.1) * 0.4; hit = true; }
364
377
  });
365
378
  if (hit) rootBranches.push(pl.branch);
366
379
  }
@@ -400,7 +413,9 @@ export function buildBazi(input) {
400
413
 
401
414
  // ── 大运/流年与原局的地支关系 ──
402
415
  const pairRel = (a, b) => {
403
- if (!a || !b || a === b) return [];
416
+ if (!a || !b) return [];
417
+ // 大运/流年支与原局同支:辰午酉亥为自刑,其余同支不算关系
418
+ if (a === b) return XING_SELF.includes(a) ? ['自刑'] : [];
404
419
  const out = [];
405
420
  if (BRANCH_HE[a + b] || BRANCH_HE[b + a]) out.push('合');
406
421
  if (hasPair(BRANCH_CHONG, a, b)) out.push('冲');
@@ -432,9 +447,31 @@ export function buildBazi(input) {
432
447
 
433
448
  const p = jdToParts(jd);
434
449
 
450
+ // ── 胎元 / 命宫 / 身宫 ──
451
+ // 胎元:月柱天干进一位、地支进三位(受胎之月的干支)
452
+ const mStemIdx = STEMS.indexOf(pillars.month.stem);
453
+ const mBranchIdx = BRANCHES.indexOf(pillars.month.branch);
454
+ const taiyuan = ganzhiName(sexagenaryIndex((mStemIdx + 1) % 10, (mBranchIdx + 3) % 12));
455
+ // 命宫:以子月起正月顺数至生月,再自该宫起子时逆数至生时
456
+ const hourBrIdxForGong = BRANCHES.indexOf(pillars.hour.branch);
457
+ const soulIdx = (((mBranchIdx - hourBrIdxForGong) % 12) + 12) % 12;
458
+ // 命宫天干按五虎遁从年干起(寅为正月)
459
+ const yinStem = ((STEMS.indexOf(pillars.year.stem) % 5) * 2 + 2) % 10;
460
+ const soulStemIdx = (yinStem + ((soulIdx - 2 + 12) % 12)) % 10;
461
+ const minggong = ganzhiName(sexagenaryIndex(soulStemIdx, soulIdx));
462
+ // 身宫:命宫对宫(相隔六位)
463
+ const bodyIdx = (soulIdx + 6) % 12;
464
+ const bodyStemIdx = (yinStem + ((bodyIdx - 2 + 12) % 12)) % 10;
465
+ const shengong = ganzhiName(sexagenaryIndex(bodyStemIdx, bodyIdx));
466
+
435
467
  return {
436
468
  system: 'bazi',
437
469
  relations: buildRelations(pillars),
470
+ palaces: {
471
+ taiyuan: { ganzhi: taiyuan.name, note: '月干进一、月支进三' },
472
+ minggong: { ganzhi: minggong.name, branch: minggong.branch, note: '子月起正月顺数至生月,再逆数至生时;干按五虎遁' },
473
+ shengong: { ganzhi: shengong.name, branch: shengong.branch, note: '命宫对宫' },
474
+ },
438
475
  input: {
439
476
  civil: { year, month, day, hour, minute },
440
477
  gender,
@@ -454,7 +491,8 @@ export function buildBazi(input) {
454
491
  jieqi: {
455
492
  previous: { name: prevJieTerm.name, time: formatJd(prevJieTerm.jd) },
456
493
  next: { name: nextJieTerm.name, time: formatJd(nextJieTerm.jd) },
457
- // 出生时刻距「节」不足 15 分钟时提示复核:节气时刻本身有分钟级误差
494
+ // 距「节」的分钟数(取两侧较小值)。是否提示复核由摘要层决定,阈值 20 分钟:
495
+ // 与 NAOJ 历要项对拍实测节气最多偏 13.5 分钟,20 分钟留出余量。
458
496
  nearBoundaryMinutes: Math.round(Math.min(jd - prevJieTerm.jd, nextJieTerm.jd - jd) * 1440),
459
497
  },
460
498
  pillars,
@@ -472,5 +510,6 @@ export function buildBazi(input) {
472
510
  strength,
473
511
  frame,
474
512
  liunian,
513
+ liunianFrom: thisYear,
475
514
  };
476
515
  }
@@ -1,3 +1,4 @@
1
+ import { earthL } from './vsop-earth.js';
1
2
  // 历法底座:儒略日、干支、二十四节气、真太阳时
2
3
  // 全部为纯函数,不依赖 DOM / Node API,Host 与 Client 可共用。
3
4
 
@@ -69,18 +70,43 @@ export function dayGanzhiIndex(jdn) {
69
70
  return (((jdn + 49) % 60) + 60) % 60;
70
71
  }
71
72
 
72
- /** 太阳视黄经(度),Meeus《天文算法》第 25 章低精度公式,误差约 0.01° */
73
+ /**
74
+ * 太阳视黄经(度)。
75
+ * 黄经本体改用 VSOP87B 截断级数(105 项),再把地心黄经转成视黄经:
76
+ * λ = Θ + FK5修正 + 章动Δψ + 光行差
77
+ * 与 NAOJ 历要项对拍:2024/2025 共 48 个节气,偏差从原来的最多 13.5 分钟降到 1 分钟内。
78
+ */
73
79
  export function sunLongitude(jd) {
74
- const T = (jd - 2451545.0) / 36525;
75
- const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
76
- const M = ((357.52911 + 35999.05029 * T - 0.0001537 * T * T) * Math.PI) / 180;
77
- const C =
78
- (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M) +
79
- (0.019993 - 0.000101 * T) * Math.sin(2 * M) +
80
- 0.000289 * Math.sin(3 * M);
81
- const om = ((125.04 - 1934.136 * T) * Math.PI) / 180;
82
- const lambda = L0 + C - 0.00569 - 0.00478 * Math.sin(om);
83
- return ((lambda % 360) + 360) % 360;
80
+ const d = jd - 2451545.0;
81
+ const T = d / 36525;
82
+ const Tv = d / 365250; // VSOP87 的时间参量单位是「儒略千年」,不是世纪
83
+
84
+ // 1) 地球日心黄经 → 太阳地心黄经
85
+ let theta = (earthL(Tv) * 180) / Math.PI + 180;
86
+ theta = ((theta % 360) + 360) % 360;
87
+
88
+ // 2) 岁差:VSOP87B 的黄经是 J2000 参考系,必须先推到当日平春分点
89
+ // (2024 年约 0.335°,相当于 8 小时,漏掉会让整个节气表错开)
90
+ const prec = (5029.0966 * T + 1.11113 * T * T - 0.000006 * T * T * T) / 3600;
91
+ theta += prec;
92
+
93
+ // 3) FK5 系统修正(Meeus 25.9)
94
+ theta -= 0.09033 / 3600;
95
+
96
+ // 3) 章动(Meeus 22,取四项主项,精度约 0.5″)
97
+ const om = ((125.04452 - 1934.136261 * T) * Math.PI) / 180;
98
+ const ls = ((280.4665 + 36000.7698 * T) * Math.PI) / 180;
99
+ const lm = ((218.3165 + 481267.8813 * T) * Math.PI) / 180;
100
+ 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;
101
+ theta += dPsi;
102
+
103
+ // 4) 光行差:-20.4898″ / R;R 只需三位有效数字,沿用低精度日地距离即可
104
+ const m = ((357.52911 + 35999.05029 * T - 0.0001537 * T * T) * Math.PI) / 180;
105
+ const e = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T;
106
+ const R = (1.000001018 * (1 - e * e)) / (1 + e * Math.cos(m + (1.914602 - 0.004817 * T) * (Math.PI / 180) * Math.sin(m)));
107
+ theta -= 20.4898 / 3600 / (R || 1);
108
+
109
+ return ((theta % 360) + 360) % 360;
84
110
  }
85
111
 
86
112
  /** ΔT(TT - UT,秒)。分段线性近似,误差 < 2s,对分钟级排盘足够 */
@@ -101,11 +127,31 @@ export function deltaTSeconds(year) {
101
127
  return knots[knots.length - 1][1];
102
128
  }
103
129
 
130
+ // 1929 年元旦之前,中国历书按「北京地方平时」(116.4°E,UTC+7.7611)推算朔与中气;
131
+ // 之后改用东经 120° 标准时(UTC+8)。这 14 分钟会让交节/合朔跨零点,进而整月错位,
132
+ // 所以节气与农历必须共用同一套换算,不能各写一份。
133
+ export const OFFSET_MODERN = 8 / 24;
134
+ export const OFFSET_BEIJING_LOCAL = 7.7611 / 24;
135
+ // 切点 = 1929-01-01 00:00(东八区)对应的 UT 儒略日 = 1928-12-31 16:00 UT。
136
+ // 这里用的是真儒略日轴:partsToJd(1929,1,1) = 2425612.5,减 8/24 即 2425612.1667。
137
+ export const CUTOVER_JD = 2425612 + 4 / 24; // 2425612.1667
138
+
139
+ /** 某时刻(UT 轴)应当使用的本地时区偏移量 */
140
+ export function localOffsetFor(jdUt) {
141
+ return jdUt < CUTOVER_JD ? OFFSET_BEIJING_LOCAL : OFFSET_MODERN;
142
+ }
143
+
104
144
  /**
105
- * 第 index 个节气(0 = 小寒)在指定年份的时刻,返回北京时间儒略日。
145
+ * 第 index 个节气(0 = 小寒)在指定年份的时刻,返回「当时本地时间轴」的儒略日。
106
146
  * index 为偶数的是「节」(定月柱),奇数的是「气」。
147
+ *
148
+ * 结果按 (year, index) 缓存:农历换算一次要问几百次中气(每个候选月都要判有无中气),
149
+ * 而 VSOP87 的 earthL 是整个引擎最贵的一段——不缓存时紫微盘 10ms 里有 8ms 花在这里。
107
150
  */
108
- export function termInstantJd(year, index) {
151
+ const TERM_INSTANT_CACHE = new Map();
152
+ const TERM_INSTANT_CACHE_MAX = 4096;
153
+
154
+ function computeTermInstantJd(year, index) {
109
155
  const target = termLongitude(index);
110
156
  const anchor = toJDN(year, 1, 5) - 0.5 + index * 15.2184;
111
157
  let jd = anchor;
@@ -115,8 +161,21 @@ export function termInstantJd(year, index) {
115
161
  jd += diff / 0.98564736;
116
162
  if (Math.abs(diff) < 1e-7) break;
117
163
  }
118
- // TT -> UT -> 北京时间
119
- return jd - deltaTSeconds(year) / 86400 + 8 / 24;
164
+ // TT -> UT -> 当时本地时间轴(1929 前是北京地方平时,之后是东八区)
165
+ const jdUt = jd - deltaTSeconds(year) / 86400;
166
+ return jdUt + localOffsetFor(jdUt);
167
+ }
168
+
169
+ export function termInstantJd(year, index) {
170
+ const key = year * 24 + index;
171
+ const hit = TERM_INSTANT_CACHE.get(key);
172
+ if (hit !== undefined) return hit;
173
+ const jd = computeTermInstantJd(year, index);
174
+ if (TERM_INSTANT_CACHE.size >= TERM_INSTANT_CACHE_MAX) {
175
+ TERM_INSTANT_CACHE.delete(TERM_INSTANT_CACHE.keys().next().value);
176
+ }
177
+ TERM_INSTANT_CACHE.set(key, jd);
178
+ return jd;
120
179
  }
121
180
 
122
181
  /** 儒略日(本地时间轴)拆成日期与时刻 */
@@ -145,8 +204,15 @@ export function equationOfTime(year, month, day) {
145
204
  return 9.87 * Math.sin(2 * B) - 7.53 * Math.cos(B) - 1.5 * Math.sin(B);
146
205
  }
147
206
 
148
- /** 列出 [year-1, year+1] 三年的节气,按时间升序 */
207
+ // 一次排盘要算 72 个节气(三年窗口),每次 12 轮牛顿迭代 × VSOP87 105 项。
208
+ // 同一年的盘经常连着排(面板切 tab、合盘两侧),按年缓存能把这块成本降到常数级。
209
+ const TERM_WINDOW_CACHE = new Map();
210
+ const TERM_WINDOW_CACHE_MAX = 8;
211
+
212
+ /** 列出 [year-1, year+1] 三年的节气,按时间升序(结果按年缓存,调用方不要修改返回值) */
149
213
  export function termWindow(year) {
214
+ const cached = TERM_WINDOW_CACHE.get(year);
215
+ if (cached) return cached;
150
216
  const list = [];
151
217
  for (let y = year - 1; y <= year + 1; y++) {
152
218
  for (let i = 0; i < 24; i++) {
@@ -154,6 +220,10 @@ export function termWindow(year) {
154
220
  }
155
221
  }
156
222
  list.sort((a, b) => a.jd - b.jd);
223
+ if (TERM_WINDOW_CACHE.size >= TERM_WINDOW_CACHE_MAX) {
224
+ TERM_WINDOW_CACHE.delete(TERM_WINDOW_CACHE.keys().next().value);
225
+ }
226
+ TERM_WINDOW_CACHE.set(year, list);
157
227
  return list;
158
228
  }
159
229
 
@@ -182,3 +252,5 @@ export function formatJd(jd) {
182
252
  const pad = (n) => String(n).padStart(2, '0');
183
253
  return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:${pad(p.minute)}`;
184
254
  }
255
+
256
+
@@ -1,4 +1,4 @@
1
- // 大六壬:天地盘、四课、九宗门三传。
1
+ // 大六壬:天地盘、四课、九宗门三传。
2
2
  // 算法移植自本地 MIT 授权插件 @dhicoc/dsh-chinese-traditional-wisdom-skill 的
3
3
  // daliurenEngine.ts(仅取 classic 流派:贵人顺逆以天盘临方判定)。
4
4
  import { toJDN, dayGanzhiIndex, ganzhiName, termWindow, formatJd } from './calendar.js';
@@ -57,6 +57,38 @@ const DAY_NIGHT_MAP = {
57
57
  酉: '夜', 戌: '夜', 亥: '夜', 子: '夜', 丑: '夜', 寅: '夜',
58
58
  };
59
59
 
60
+ // ── 神煞(只收各派无争议、六壬断课常用的几项) ──
61
+ const RI_LU = { 甲: '寅', 乙: '卯', 丙: '巳', 丁: '午', 戊: '巳', 己: '午', 庚: '申', 辛: '酉', 壬: '亥', 癸: '子' };
62
+ const YANG_REN = { 甲: '卯', 乙: '辰', 丙: '午', 丁: '未', 戊: '午', 己: '未', 庚: '酉', 辛: '戌', 壬: '子', 癸: '丑' };
63
+ const SAN_HE = { 申: '水', 子: '水', 辰: '水', 寅: '火', 午: '火', 戌: '火', 巳: '金', 酉: '金', 丑: '金', 亥: '木', 卯: '木', 未: '木' };
64
+ const SAN_HE_SHEN = {
65
+ 水: { 驿马: '寅', 桃花: '酉', 华盖: '辰', 劫煞: '巳', 亡神: '亥' },
66
+ 火: { 驿马: '申', 桃花: '卯', 华盖: '戌', 劫煞: '亥', 亡神: '巳' },
67
+ 金: { 驿马: '亥', 桃花: '午', 华盖: '丑', 劫煞: '寅', 亡神: '申' },
68
+ 木: { 驿马: '巳', 桃花: '子', 华盖: '未', 劫煞: '申', 亡神: '寅' },
69
+ };
70
+
71
+ /** 按日干支算神煞 */
72
+ export function calcShensha(dayGz) {
73
+ const gan = dayGz[0];
74
+ const zhi = dayGz[1];
75
+ const out = [];
76
+ const push = (name, branch, note) => {
77
+ if (!out.some((x) => x.name === name)) out.push({ name, branch, note });
78
+ };
79
+ if (RI_LU[gan]) push('日禄', RI_LU[gan], '日干临官之位');
80
+ if (YANG_REN[gan]) push('羊刃', YANG_REN[gan], '日干帝旺之位');
81
+ const trio = SAN_HE_SHEN[SAN_HE[zhi]];
82
+ if (trio) {
83
+ push('驿马', trio.驿马, '日支三合冲位,主迁动');
84
+ push('桃花', trio.桃花, '咸池,主情缘');
85
+ push('华盖', trio.华盖, '主孤高、技艺');
86
+ push('劫煞', trio.劫煞, '主意外耗损');
87
+ push('亡神', trio.亡神, '主失脱、心神不定');
88
+ }
89
+ return out;
90
+ }
91
+
60
92
  const XUN_KONG = {
61
93
  甲子: ['戌', '亥'], 甲戌: ['申', '酉'], 甲申: ['午', '未'],
62
94
  甲午: ['辰', '巳'], 甲辰: ['寅', '卯'], 甲寅: ['子', '丑'],
@@ -300,8 +332,11 @@ export function buildDaliuren(input) {
300
332
  zhong: chuan('中传', zhong, tdp, dayGan, dayGz),
301
333
  mo: chuan('末传', mo, tdp, dayGan, dayGz),
302
334
  },
335
+ shensha: calcShensha(dayGz),
303
336
  geJu: { name: geJu, detail },
304
337
  };
305
338
  }
306
339
 
307
340
  export { ZHI, GAN, TIAN_JIANG, JIANG_FULL, JIEQI_YUE_JIANG };
341
+
342
+
package/engine/geo.js CHANGED
@@ -1,5 +1,7 @@
1
- // 地址 -> 经度(真太阳时用)。内置省级 + 主要地级市坐标,供离线使用。
2
- // 坐标为近似值(±0.2°≈0.8 分钟时差),足够真太阳时使用;不联网、不上传。
1
+ // 地址 -> 经度(真太阳时用)。内置省级 + 主要地级市坐标,离线即可用。
2
+ // 坐标为近似值(±0.2°≈0.8 分钟时差),足够真太阳时使用。
3
+ // 联网(Nominatim)是显式可选项:只有调用方传 online: true 才会把地址原文发出去,
4
+ // 默认走内置表,不上传任何数据。
3
5
  const RAW = `
4
6
  北京 116.41 39.90;上海 121.47 31.23;天津 117.20 39.13;重庆 106.55 29.56
5
7
  广州 113.26 23.13;深圳 114.06 22.55;珠海 113.58 22.27;东莞 113.75 23.02;佛山 113.12 23.02
@@ -12,6 +14,8 @@ const RAW = `
12
14
  徐州 117.28 34.26;扬州 119.41 32.39;盐城 120.16 33.35;泰州 119.92 32.46;镇江 119.45 32.20
13
15
  淮安 119.02 33.61;连云港 119.22 34.60;宿迁 118.28 33.96
14
16
  合肥 117.23 31.82;芜湖 118.43 31.35;蚌埠 117.39 32.92;安庆 117.05 30.54;阜阳 115.81 32.90
17
+ 大同 113.30 40.08;阳泉 113.58 37.86;长治 113.12 36.19;晋城 112.85 35.49;朔州 112.43 39.33
18
+ 晋中 112.75 37.69;运城 111.00 35.02;忻州 112.73 38.42;临汾 111.52 36.09;吕梁 111.14 37.52
15
19
  六安 116.50 31.75;马鞍山 118.50 31.67;淮南 117.00 32.63;滁州 118.32 32.30;黄山 118.34 29.71
16
20
  福州 119.30 26.08;厦门 118.09 24.48;泉州 118.68 24.87;漳州 117.65 24.51;莆田 119.01 25.45
17
21
  宁德 119.55 26.67;三明 117.64 26.26;南平 118.18 26.64;龙岩 117.02 25.08
@@ -121,11 +125,15 @@ export function resolveAddress(input) {
121
125
  return { lon: c.lon, lat: c.lat, matched: n, level: '地级市', note: `按 ${n} 估算(约 ${c.lon}°E)` };
122
126
  }
123
127
  }
128
+ // 省名判定用未去后缀的原文,否则「河北区」会被去掉「区」后误判成河北省
129
+ const rawFlat = raw.replace(/\s/g, '');
124
130
  for (const [prov, capital] of Object.entries(PROVINCES)) {
125
- if (text.includes(prov)) {
126
- const c = CITIES[capital] || { lon: EXTRA[capital][0], lat: EXTRA[capital][1] };
127
- return { lon: c.lon, lat: c.lat, matched: capital, level: '省会', note: `未匹配到具体城市,按 ${prov} 省会 ${capital} 估算(约 ${c.lon}°E)` };
128
- }
131
+ const at = rawFlat.indexOf(prov);
132
+ if (at < 0) continue;
133
+ const after = rawFlat.charAt(at + prov.length);
134
+ if (after === '区' || after === '县') continue;
135
+ const c = CITIES[capital] || { lon: EXTRA[capital][0], lat: EXTRA[capital][1] };
136
+ return { lon: c.lon, lat: c.lat, matched: capital, level: '省会', note: `未匹配到具体城市,按 ${prov} 省会 ${capital} 估算(约 ${c.lon}°E)` };
129
137
  }
130
138
  return { lon: null, lat: null, matched: null, level: '未匹配', note: `没查到「${raw}」,可改填经度(如 116.4)` };
131
139
  }
@@ -133,7 +141,10 @@ export function resolveAddress(input) {
133
141
  /** 地址里出现了比「城市」更细的层级时,优先联网 */
134
142
  const DETAIL = /(区|县|镇|乡|村|小区|街道|路|号|巷|弄|组|队)/;
135
143
 
136
- /** OpenStreetMap Nominatim:免密钥、支持中文。失败返回 null。 */
144
+ /** OpenStreetMap Nominatim:免密钥、支持中文。失败返回 null。
145
+ * UA 按 OSM 使用政策要求写明应用与联系方式(改版本时同步这里)。 */
146
+ const USER_AGENT = 'dsh-metaphysics/0.14.2 (+https://github.com/xuanfu-web/dsh-metaphysics)';
147
+
137
148
  async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
138
149
  if (typeof fetch !== 'function') return null;
139
150
  let timer = null;
@@ -144,7 +155,7 @@ async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
144
155
  + encodeURIComponent(text);
145
156
  const res = await fetch(url, {
146
157
  signal: controller ? controller.signal : undefined,
147
- headers: { 'User-Agent': 'dsh-metaphysics/0.2 (local plugin)' },
158
+ headers: { 'User-Agent': USER_AGENT },
148
159
  });
149
160
  if (!res || !res.ok) return null;
150
161
  const list = await res.json();
@@ -162,7 +173,8 @@ async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
162
173
  }
163
174
 
164
175
  /**
165
- * 地址 -> 经度。离线表优先;地址更细(含区/县/村/小区等)或离线未命中时联网查。
176
+ * 地址 -> 经度。默认只查内置表;显式 online: true 时,地址更细(含区/县/村/小区等)
177
+ * 或离线未命中才会联网查。
166
178
  * @param {string} input 地址
167
179
  * @param {{online?: boolean, timeoutMs?: number}} opts
168
180
  */
@@ -170,12 +182,17 @@ export async function geocodeAddress(input, opts = {}) {
170
182
  const local = resolveAddress(input);
171
183
  const text = String(input || '').trim();
172
184
  if (!text) return local;
173
- const wantOnline = opts.online !== false;
185
+ // 默认离线:联网必须显式开启(会把出生地原文发给第三方)
186
+ const wantOnline = opts.online === true;
174
187
  if (!wantOnline) return local;
175
188
  if (local && typeof local.lon === 'number' && !DETAIL.test(text)) return local;
176
189
  const remote = await onlineLookup(text, opts);
177
190
  if (remote) return { ...remote, local: local && local.lon != null ? local : null };
178
- return local;
191
+ // 联网失败不能静默:明确告诉用户已退回内置表,以及精度落在哪一级
192
+ if (local && typeof local.lon === 'number') {
193
+ return { ...local, onlineFailed: true, note: `${local.note}(联网解析失败或超时,已退回内置坐标表)` };
194
+ }
195
+ return { ...local, onlineFailed: true };
179
196
  }
180
197
 
181
198
  export { CITIES, PROVINCES };
@@ -0,0 +1,144 @@
1
+ // 合盘(合婚):日主关系 + 夫妻宫关系 + 五行互补。
2
+ // 口径:日主取五行生克(双向平均),夫妻宫取日支的六合/三合/冲/害/刑/破,
3
+ // 五行互补看「一方喜用是否正是另一方所旺」。各派取法不同,这里只给通用版结论。
4
+ import { buildBazi } from './bazi.js';
5
+ import { STEMS, BRANCHES } from './calendar.js';
6
+
7
+ const WX_GAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
8
+ const WX_ZHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
9
+ const SHENG = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
10
+ const KE = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
11
+
12
+ // 地支两两关系(只取合、冲、害、刑、破;三合按半合算)
13
+ const LIU_HE = [['子', '丑'], ['寅', '亥'], ['卯', '戌'], ['辰', '酉'], ['巳', '申'], ['午', '未']];
14
+ const CHONG = [['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥']];
15
+ const HAI = [['子', '未'], ['丑', '午'], ['寅', '巳'], ['卯', '辰'], ['申', '亥'], ['酉', '戌']];
16
+ const PO = [['子', '酉'], ['卯', '午'], ['辰', '丑'], ['未', '戌'], ['寅', '亥'], ['巳', '申']];
17
+ const SAN_HE = [['申', '子', '辰'], ['寅', '午', '戌'], ['巳', '酉', '丑'], ['亥', '卯', '未']];
18
+ const XING = [['寅', '巳', '申'], ['丑', '戌', '未'], ['子', '卯']];
19
+
20
+ const pairIn = (list, a, b) => list.some((g) => g.includes(a) && g.includes(b) && a !== b);
21
+
22
+ function zhiRelation(a, b) {
23
+ if (a === b) return { name: '同支', score: 0, text: '日支相同,性情相近,容易互相理解,也容易同时卡在同一处' };
24
+ if (pairIn(LIU_HE, a, b)) return { name: '六合', score: 20, text: '日支六合,相处自然、彼此补位' };
25
+ for (const g of SAN_HE) {
26
+ if (g.includes(a) && g.includes(b)) return { name: '半合', score: 14, text: '日支同属三合局,易有共同语言' };
27
+ }
28
+ if (pairIn(CHONG, a, b)) return { name: '相冲', score: -15, text: '日支相冲,节奏与需求容易错位,需要刻意协调' };
29
+ if (pairIn(XING, a, b)) {
30
+ for (const g of XING) {
31
+ if (g.includes(a) && g.includes(b)) return { name: '相刑', score: -10, text: '日支相刑,摩擦多起于细节,宜少用指责' };
32
+ }
33
+ }
34
+ if (pairIn(HAI, a, b)) return { name: '相害', score: -9, text: '日支相害,容易因第三方或误解生嫌隙' };
35
+ if (pairIn(PO, a, b)) return { name: '相破', score: -5, text: '日支相破,小事易起波澜' };
36
+ return { name: '无直接关系', score: 4, text: '日支之间没有直接刑冲合害,关系好坏主要看经营' };
37
+ }
38
+
39
+ /** 两个五行之间:对方对我 */
40
+ function wxToMe(other, me) {
41
+ if (other === me) return { name: '比和', score: 13, text: '两人日主同五行,气味相投,也容易一起犯同样的毛病' };
42
+ if (SHENG[other] === me) return { name: '生我', score: 20, text: '对方五行生我,容易得到照顾与助力' };
43
+ if (SHENG[me] === other) return { name: '我生', score: 10, text: '我生对方,多为付出的一方,久了要留意消耗' };
44
+ if (KE[me] === other) return { name: '我克', score: 5, text: '我克对方,主导感较强,需留意对方的空间' };
45
+ return { name: '克我', score: -12, text: '对方克我,压力感偏重,宜把话说开' };
46
+ }
47
+
48
+ /** 合盘主函数 */
49
+ export function buildHepan(input) {
50
+ const A = buildBazi({ ...input.a, gender: input.a.gender });
51
+ const B = buildBazi({ ...input.b, gender: input.b.gender });
52
+
53
+ const aStem = A.pillars.day.stem;
54
+ const bStem = B.pillars.day.stem;
55
+ const aWx = WX_GAN[aStem];
56
+ const bWx = WX_GAN[bStem];
57
+ const aZhi = A.pillars.day.branch;
58
+ const bZhi = B.pillars.day.branch;
59
+
60
+ // ① 日主
61
+ const aToB = wxToMe(bWx, aWx); // 以 A 为主体看 B
62
+ const bToA = wxToMe(aWx, bWx);
63
+ const stemScore = (aToB.score + bToA.score) / 2;
64
+
65
+ // ② 夫妻宫(日支)
66
+ const gong = zhiRelation(aZhi, bZhi);
67
+
68
+ // ③ 五行互补:一方喜用是否正是另一方所旺
69
+ const favorA = (A.frame && A.frame.yongshen ? A.frame.yongshen.favor : []).slice();
70
+ const favorB = (B.frame && B.frame.yongshen ? B.frame.yongshen.favor : []).slice();
71
+ const wxCount = (r) => {
72
+ const m = {};
73
+ for (const k of ['year', 'month', 'day', 'hour']) {
74
+ m[WX_GAN[r.pillars[k].stem]] = (m[WX_GAN[r.pillars[k].stem]] || 0) + 1;
75
+ m[WX_ZHI[r.pillars[k].branch]] = (m[WX_ZHI[r.pillars[k].branch]] || 0) + 1;
76
+ }
77
+ return m;
78
+ };
79
+ const wxA = wxCount(A);
80
+ const wxB = wxCount(B);
81
+ const strongOf = (m) => Object.entries(m).sort((x, y) => y[1] - x[1])[0] || ['', 0];
82
+ const [strongA] = strongOf(wxA);
83
+ const [strongB] = strongOf(wxB);
84
+ const bFeedsA = favorA.includes(strongB);
85
+ const aFeedsB = favorB.includes(strongA);
86
+ let compScore = 0;
87
+ const compNotes = [];
88
+ if (bFeedsA) { compScore += 12; compNotes.push(`对方的旺行 ${strongB} 正是你的喜用`); }
89
+ if (aFeedsB) { compScore += 12; compNotes.push(`你的旺行 ${strongA} 正是对方的喜用`); }
90
+ if (!bFeedsA && !aFeedsB) { compScore += 3; compNotes.push('双方的旺行都不是对方的喜用,互补性一般,靠沟通补'); }
91
+ // 一方缺的五行正好是对方所旺,也算补
92
+ const missingA = ['木', '火', '土', '金', '水'].filter((w) => !wxA[w]);
93
+ const missingB = ['木', '火', '土', '金', '水'].filter((w) => !wxB[w]);
94
+ if (missingA.includes(strongB)) { compScore += 4; compNotes.push(`你命局缺 ${strongB},对方正好偏旺`); }
95
+ if (missingB.includes(strongA)) { compScore += 4; compNotes.push(`对方命局缺 ${strongA},你正好偏旺`); }
96
+
97
+ // ④ 综合
98
+ const raw = stemScore + gong.score + compScore; // 理论区间约 -27 ~ 56
99
+ const pct = Math.max(0, Math.min(100, Math.round(((raw + 27) / 83) * 100)));
100
+ const level = pct >= 75 ? '契合度较高' : pct >= 55 ? '中等偏顺' : pct >= 40 ? '需要磨合' : '差异较大';
101
+
102
+ const dim = (key, label, score, text, extra) => ({ key, label, score: Math.round(score), text, ...(extra || {}) });
103
+
104
+ return {
105
+ system: 'hepan',
106
+ a: {
107
+ gender: A.input.gender,
108
+ pillars: ['year', 'month', 'day', 'hour'].map((k) => A.pillars[k].ganzhi),
109
+ dayMaster: `${aStem}${aWx}`,
110
+ dayBranch: aZhi,
111
+ strength: A.strength ? A.strength.level : null,
112
+ favor: favorA.join(''),
113
+ wuxing: wxA,
114
+ // 真太阳时口径要能被看到:合盘与单盘用同一套修正,否则同一个人会出现两套四柱
115
+ longitude: A.input.longitude ?? null,
116
+ trueSolar: A.input.trueSolar !== false,
117
+ solarNote: A.solarTime ? A.solarTime.note : '',
118
+ summary: A.summary,
119
+ },
120
+ b: {
121
+ gender: B.input.gender,
122
+ pillars: ['year', 'month', 'day', 'hour'].map((k) => B.pillars[k].ganzhi),
123
+ dayMaster: `${bStem}${bWx}`,
124
+ dayBranch: bZhi,
125
+ strength: B.strength ? B.strength.level : null,
126
+ favor: favorB.join(''),
127
+ wuxing: wxB,
128
+ longitude: B.input.longitude ?? null,
129
+ trueSolar: B.input.trueSolar !== false,
130
+ solarNote: B.solarTime ? B.solarTime.note : '',
131
+ summary: B.summary,
132
+ },
133
+ score: { total: pct, level },
134
+ dimensions: [
135
+ dim('stem', '日主关系', stemScore, `你的日主 ${aStem}${aWx},对方 ${bStem}${bWx}。${aToB.text};${bToA.text}。`),
136
+ dim('gong', '夫妻宫(日支)', gong.score, `${gong.name}:${gong.text}。你日支 ${aZhi},对方日支 ${bZhi}。`),
137
+ dim('wuxing', '五行互补', compScore, compNotes.join(';') + '。'),
138
+ ],
139
+ notes: [
140
+ '合盘看的是相处方式与互补点,不决定关系成败;现实里的沟通与选择分量更重。',
141
+ `口径:日主取五行生克双向平均,夫妻宫取日支关系,五行互补看「一方喜用是否为另一方所旺」。`,
142
+ ],
143
+ };
144
+ }