付録D_参照実装エンジン

付録D 参照実装エンジン(engine.js 全文)

算命学ロジック完全仕様 の正となる実装。JavaScript(ブラウザ・Node 共用、1425行)。外部ライブラリには依存しない。

本編(共有ページ)

本編とずれていたら、このコードが正しい。

使い方(Node):

const SANMEI = require('./engine.js');
SANMEI.init(setsuTable, moonTable);          // 付録A の JSON を渡す。moonTable は省略可(月相が null になる)
global.window = { HOLIDAY_TABLE: holidayTable }; // 祝日表(ブラウザでは window に置く)
const a = SANMEI.meishiki(1984, 10, 10, { hour: 14 });

目次(行番号は下のコードブロック内)

行 名前
15 KAN
16 SHI
18 GOGYO_KAN
19 GOGYO_SHI
21 INYO_KAN
23 SEI
24 sho
25 koku
27 SHUSEI_LIST
28 JUSEI_LIST
31 shusei
44 CHOSEI
45 UNJUN
46 JU_MAP
53 jusei
70 ZOKAN_KANTEI
85 ZOKAN_A
94 HONGEN
96 ZOKAN_TABLES
98 zokan
106 zokanStages
114 TENCHUSATSU
116 TENCHUSATSU_SHI
122 IJOU_KANSHI
127 IJOU_KANSHI_7
130 ijouKanshi
136 KANGO
139 GOSO
146 jichu
154 jdn
160 fromJdn
173 WEEKDAY
174 weekday
182 SETSU_SHI
183 SETSU_NAME
185 GOTORA
195 init
203 initMoon
220 pad2
223 moonPhaseOn
230 moonPhases
249 range
254 diffDays
259 assertInRange
267 findSetsu
285 findNextSetsu
302 kanshiIndex
310 kanshiYear
325 meishiki
446 daiun
490 SHIGO
491 CHU
492 SANGO
493 KEI3
494 KEI2
495 JIKEI
496 GAI
497 HA
500 isou
514 isouInner
529 suurihou
552 SHINKYO_STARS
553 SHINJAKU_STARS
554 shinkyo
580 RYOIKI
586 ryoikiOf
588 koudouRyoiki
638 ROKUSHIN_ORDER
642 TEIICHI
647 gyoOf
648 yinOf
649 mkKan
650 kangoOf
651 koibitoOf
661 rokushin
738 STAR_GOGYO
751 TEIICHI_POS
766 KYOUIKU_KANSHI
779 INSEI
780 ROSEI
781 KYOUIKU_PAIR
805 kyouikuSei
823 kyouiku
853 SETSUGETSU_SHI
856 ICHIRYU
863 SANRINBOU
866 TENSHA
877 kichijitsu
900 holidayTable
908 dayOfYear
916 holiday
927 hasHolidayData
932 teiichi
962 sainou
1019 REL_TEXT
1028 REL_MODE_TEXT
1057 TCS_MODE_TEXT
1079 AISHO_MODES
1085 TCS_TEXT
1091 ISOU_TEXT
1102 ROLE_TEXT
1114 SHINKYO_PAIR
1126 aisho
1201 gyoun
1239 HOUR_SLOTS
1255 gyounList
1333 search
1368 matches
1383 missing
1395 searchNearMiss

/**
 * 算命学 命式エンジン
 *
 * 設計上の注意:
 * - 日付演算は Date を一切使わず、ユリウス日(JDN)の整数演算で行う。
 *   ブラウザのタイムゾーンに結果が左右されないため。
 * - 節入りテーブル(setsu_table.json)は JST の日時。各暦年に12個で、
 *   配列 index 0..11 が 1月..12月 の「節」に対応する。
 *   1月=小寒(丑月) 2月=立春(寅月) 3月=啓蟄(卯月) ... 12月=大雪(子月)
 * - 蔵干の取り方は流派差が大きいので variant で切り替えられるようにしている。
 */
const SANMEI = (function () {
  'use strict';

  const KAN = '甲乙丙丁戊己庚辛壬癸';
  const SHI = '子丑寅卯辰巳午未申酉戌亥';

  const GOGYO_KAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
  const GOGYO_SHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
  // 1 = 陽, 0 = 陰
  const INYO_KAN = { 甲: 1, 乙: 0, 丙: 1, 丁: 0, 戊: 1, 己: 0, 庚: 1, 辛: 0, 壬: 1, 癸: 0 };

  const SEI = ['木', '火', '土', '金', '水']; // 相生の順
  const sho = (a, b) => SEI[(SEI.indexOf(a) + 1) % 5] === b; // a が b を生じる
  const koku = (a, b) => SEI[(SEI.indexOf(a) + 2) % 5] === b; // a が b を剋す

  const SHUSEI_LIST = ['貫索星', '石門星', '鳳閣星', '調舒星', '禄存星', '司禄星', '車騎星', '牽牛星', '龍高星', '玉堂星'];
  const JUSEI_LIST = ['天報星', '天印星', '天貴星', '天恍星', '天南星', '天禄星', '天将星', '天堂星', '天胡星', '天極星', '天庫星', '天馳星'];

  /** 十大主星: 日干から見た対象干の関係で決まる */
  function shusei(nikkan, target) {
    const ea = GOGYO_KAN[nikkan];
    const eb = GOGYO_KAN[target];
    const same = INYO_KAN[nikkan] === INYO_KAN[target];
    if (ea === eb) return same ? '貫索星' : '石門星';
    if (sho(ea, eb)) return same ? '鳳閣星' : '調舒星';
    if (koku(ea, eb)) return same ? '禄存星' : '司禄星';
    if (koku(eb, ea)) return same ? '車騎星' : '牽牛星';
    if (sho(eb, ea)) return same ? '龍高星' : '玉堂星';
    throw new Error('主星判定に失敗: ' + nikkan + ' / ' + target);
  }

  // 十二運。陽干は長生位から順行、陰干は逆行。
  const CHOSEI = { 甲: '亥', 乙: '午', 丙: '寅', 丁: '酉', 戊: '寅', 己: '酉', 庚: '巳', 辛: '子', 壬: '申', 癸: '卯' };
  const UNJUN = ['長生', '沐浴', '冠帯', '建禄', '帝旺', '衰', '病', '死', '墓', '絶', '胎', '養'];
  const JU_MAP = {
    胎: ['天報星', 3], 養: ['天印星', 6], 長生: ['天貴星', 9], 沐浴: ['天恍星', 7],
    冠帯: ['天南星', 10], 建禄: ['天禄星', 11], 帝旺: ['天将星', 12], 衰: ['天堂星', 8],
    病: ['天胡星', 4], 死: ['天極星', 2], 墓: ['天庫星', 5], 絶: ['天馳星', 1],
  };

  /** 十二大従星: 日干と地支の十二運から決まる */
  function jusei(nikkan, shi) {
    const start = SHI.indexOf(CHOSEI[nikkan]);
    const pos = SHI.indexOf(shi);
    const step = INYO_KAN[nikkan] === 1 ? 1 : -1;
    const idx = (((pos - start) * step) % 12 + 12) % 12;
    const [name, energy] = JU_MAP[UNJUN[idx]];
    return { name, energy, unsei: UNJUN[idx] };
  }

  // 蔵干テーブル。[経過日数の上限, 干] の並び。経過日数は節入り日を1日目として数える。
  //
  // KANTEI は kantei-app.com の出力を実測して起こしたもの(2000年の各月を1日刻みで観測)。
  // 支によって段数が違うのがこの表の特徴:
  //   1段(専気) … 子=癸, 卯=乙, 酉=辛
  //   2段        … 午(己→丁), 亥(甲→壬)   ← 初元を持たない
  //   3段        … 残りの7支
  // 土用系(丑辰未戌)は 10日/13日 で揃うが、四生(寅巳申)は支ごとに境界が違う。
  const ZOKAN_KANTEI = {
    子: [[99, '癸']],
    丑: [[10, '癸'], [13, '辛'], [99, '己']],
    寅: [[8, '戊'], [15, '丙'], [99, '甲']],
    卯: [[99, '乙']],
    辰: [[10, '乙'], [13, '癸'], [99, '戊']],
    巳: [[6, '戊'], [15, '庚'], [99, '丙']],
    午: [[20, '己'], [99, '丁']],
    未: [[10, '丁'], [13, '乙'], [99, '己']],
    申: [[11, '戊'], [14, '壬'], [99, '庚']],
    酉: [[99, '辛']],
    戌: [[10, '辛'], [13, '丁'], [99, '戊']],
    亥: [[13, '甲'], [99, '壬']],
  };
  // 比較用に残している他流派の区分(四柱推命の月律分野蔵干に近いもの)
  const ZOKAN_A = {
    子: [[9, '壬'], [99, '癸']], 丑: [[9, '癸'], [12, '辛'], [99, '己']],
    寅: [[7, '戊'], [14, '丙'], [99, '甲']], 卯: [[10, '甲'], [99, '乙']],
    辰: [[9, '乙'], [12, '癸'], [99, '戊']], 巳: [[7, '戊'], [14, '庚'], [99, '丙']],
    午: [[10, '丙'], [19, '己'], [99, '丁']], 未: [[9, '丁'], [12, '乙'], [99, '己']],
    申: [[7, '戊'], [14, '壬'], [99, '庚']], 酉: [[10, '庚'], [99, '辛']],
    戌: [[9, '辛'], [12, '丁'], [99, '戊']], 亥: [[7, '戊'], [14, '甲'], [99, '壬']],
  };
  // 本元(正気)のみ。年支・日支は本元固定で取る流派向け。
  const HONGEN = { 子: '癸', 丑: '己', 寅: '甲', 卯: '乙', 辰: '戊', 巳: '丙', 午: '丁', 未: '己', 申: '庚', 酉: '辛', 戌: '戊', 亥: '壬' };

  const ZOKAN_TABLES = { kantei: ZOKAN_KANTEI, A: ZOKAN_A };

  function zokan(shi, keika, variant) {
    if (variant === 'hongen') return HONGEN[shi];
    const tbl = ZOKAN_TABLES[variant] || ZOKAN_KANTEI;
    for (const [lim, k] of tbl[shi]) if (keika <= lim) return k;
    return tbl[shi][tbl[shi].length - 1][1];
  }

  /** 蔵干の3段(初元・中元・本元)をそのまま返す。段を持たない支は null が入る。 */
  function zokanStages(shi) {
    const rows = ZOKAN_KANTEI[shi];
    const kans = rows.map((r) => r[1]);
    if (kans.length === 3) return { 初元: kans[0], 中元: kans[1], 本元: kans[2] };
    if (kans.length === 2) return { 初元: null, 中元: kans[0], 本元: kans[1] };
    return { 初元: null, 中元: null, 本元: kans[0] };
  }

  const TENCHUSATSU = ['戌亥天中殺', '申酉天中殺', '午未天中殺', '辰巳天中殺', '寅卯天中殺', '子丑天中殺'];
  // 天中殺の名前 -> 該当する2支。行運(大運・年運・月運・日運・時運)の天中殺判定に使う。
  const TENCHUSATSU_SHI = {
    子丑天中殺: ['子', '丑'], 寅卯天中殺: ['寅', '卯'], 辰巳天中殺: ['辰', '巳'],
    午未天中殺: ['午', '未'], 申酉天中殺: ['申', '酉'], 戌亥天中殺: ['戌', '亥'],
  };

  // 異常干支。13個説(通常6+暗合7)を既定にした。複数の解説サイトで一致する分類。
  const IJOU_KANSHI = {
    甲戌: '通常', 乙亥: '通常', 戊戌: '通常', 庚子: '通常', 辛亥: '通常', 丁巳: '通常',
    辛巳: '暗合', 壬午: '暗合', 丁亥: '暗合', 丙戌: '暗合', 戊子: '暗合', 癸巳: '暗合', 己亥: '暗合',
  };
  // 朱学院系の7個説(参考用。甲午を含み、通常異常干支を採らない)
  const IJOU_KANSHI_7 = ['甲午', '丁亥', '戊子', '己亥', '辛巳', '壬午', '癸巳'];

  /** 干支が異常干支かどうか。set='13'(既定)or '7' */
  function ijouKanshi(kanshi, set) {
    if (set === '7') return IJOU_KANSHI_7.includes(kanshi) ? '異常干支' : null;
    return IJOU_KANSHI[kanshi] || null;
  }

  // 干合(かんごう)の相手。伴星(人体図の左上「ご先祖様」)を出すのに使う。
  const KANGO = { 甲: '己', 己: '甲', 乙: '庚', 庚: '乙', 丙: '辛', 辛: '丙', 丁: '壬', 壬: '丁', 戊: '癸', 癸: '戊' };

  // 五鼠遁(日干 -> 子の刻の天干)。時柱を出すのに使う。
  const GOSO = { 甲: '甲', 己: '甲', 乙: '丙', 庚: '丙', 丙: '戊', 辛: '戊', 丁: '庚', 壬: '庚', 戊: '壬', 癸: '壬' };

  /**
   * 時柱を求める。
   * 時支は 23〜1時=子、1〜3時=丑 … の2時間区切り。
   * 23時台を翌日の日干支で扱う流派(夜子時)もあるが、ここでは日付をまたがない扱いにしている。
   */
  function jichu(nikkan, hour) {
    const idx = Math.floor(((hour + 1) % 24) / 2);
    const shi = SHI[idx];
    const kan = KAN[(KAN.indexOf(GOSO[nikkan]) + idx) % 10];
    return { kan, shi, kanshi: kan + shi, shiIndex: idx };
  }

  // ---- 日付ユーティリティ (Date は使わない) ----
  function jdn(y, m, d) {
    const a = Math.floor((14 - m) / 12);
    const y2 = y + 4800 - a;
    const m2 = m + 12 * a - 3;
    return d + Math.floor((153 * m2 + 2) / 5) + 365 * y2 + Math.floor(y2 / 4) - Math.floor(y2 / 100) + Math.floor(y2 / 400) - 32045;
  }
  function fromJdn(j) {
    const a = j + 32044;
    const b = Math.floor((4 * a + 3) / 146097);
    const c = a - Math.floor((146097 * b) / 4);
    const d2 = Math.floor((4 * c + 3) / 1461);
    const e = c - Math.floor((1461 * d2) / 4);
    const m2 = Math.floor((5 * e + 2) / 153);
    return [
      100 * b + d2 - 4800 + Math.floor(m2 / 10),
      m2 + 3 - 12 * Math.floor(m2 / 10),
      e - Math.floor((153 * m2 + 2) / 5) + 1,
    ];
  }
  const WEEKDAY = ['月', '火', '水', '木', '金', '土', '日'];
  function weekday(y, m, d) {
    // JDN 2451545 (2000-01-01) は土曜。2451545 % 7 === 5 なので
    // WEEKDAY を月曜起点で並べると jdn % 7 がそのまま曜日 index になる。
    const j = jdn(y, m, d);
    return WEEKDAY[((j % 7) + 7) % 7];
  }

  // 節入り index(0..11) -> 月支。1月=小寒=丑月 ... 12月=大雪=子月
  const SETSU_SHI = ['丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥', '子'];
  const SETSU_NAME = ['小寒', '立春', '啓蟄', '清明', '立夏', '芒種', '小暑', '立秋', '白露', '寒露', '立冬', '大雪'];
  // 五虎遁: 年干 -> 寅月の月干
  const GOTORA = { 甲: '丙', 己: '丙', 乙: '戊', 庚: '戊', 丙: '庚', 辛: '庚', 丁: '壬', 壬: '壬', 戊: '甲', 癸: '甲' };

  let TABLE = null;
  let MOON = null;          // [{jdn, hh, mm, kind}] 昇順
  let MOON_BY_JDN = null;   // jdn -> 月相(検索で毎日引くので Map にしておく)

  /**
   * @param {object} table 節入りテーブル(必須)
   * @param {object} moonTable 新月・満月テーブル(省略可。渡さないと月相は使えない)
   */
  function init(table, moonTable) {
    TABLE = table;
    MOON = null;
    MOON_BY_JDN = null;
    if (moonTable) initMoon(moonTable);
  }

  /** 差分エンコードされた月相テーブルを展開する */
  function initMoon(table) {
    const ep = table.epoch;
    const epMin = jdn(ep[0], ep[1], ep[2]) * 1440;
    MOON = [];
    MOON_BY_JDN = new Map();
    let cum = 0;
    table.step.forEach((s, i) => {
      cum += s;
      const total = epMin + cum;
      const j = Math.floor(total / 1440);
      const rem = total - j * 1440;
      const e = { jdn: j, hh: Math.floor(rem / 60), mm: rem % 60, kind: i % 2 === 0 ? '新月' : '満月' };
      MOON.push(e);
      MOON_BY_JDN.set(j, e);   // 同じ日に新月と満月が両方来ることはない
    });
  }

  const pad2 = (n) => String(n).padStart(2, '0');

  /** その日が新月か満月か。該当しなければ null */
  function moonPhaseOn(y, m, d) {
    if (!MOON_BY_JDN) return null;
    const e = MOON_BY_JDN.get(jdn(y, m, d));
    return e ? { kind: e.kind, time: pad2(e.hh) + ':' + pad2(e.mm) } : null;
  }

  /** 期間内の新月・満月を列挙する */
  function moonPhases(fromIso, toIso) {
    if (!MOON) return [];
    const [fy, fm, fd] = fromIso.split('-').map(Number);
    const [ty, tm, td] = toIso.split('-').map(Number);
    const j0 = jdn(fy, fm, fd);
    const j1 = jdn(ty, tm, td);
    return MOON.filter((e) => e.jdn >= j0 && e.jdn <= j1).map((e) => {
      const [y, m, d] = fromJdn(e.jdn);
      return {
        date: y + '-' + pad2(m) + '-' + pad2(d),
        time: pad2(e.hh) + ':' + pad2(e.mm),
        kind: e.kind,
      };
    });
  }
  /**
   * 計算できる年の範囲。1月上旬は前年12月の節(大雪)を参照するので、
   * テーブル最初の年は完全には扱えない。min は 1 足した「安全に計算できる下限」を返す。
   */
  function range() {
    const years = Object.keys(TABLE).map(Number).sort((a, b) => a - b);
    return { min: years[0] + 1, max: years[years.length - 1] };
  }
  /** 'YYYY-MM-DD' 同士の日数差 */
  function diffDays(fromIso, toIso) {
    const [fy, fm, fd] = fromIso.split('-').map(Number);
    const [ty, tm, td] = toIso.split('-').map(Number);
    return jdn(ty, tm, td) - jdn(fy, fm, fd);
  }
  function assertInRange(y) {
    const r = range();
    if (y < r.min || y > r.max) {
      throw new Error('対応しているのは ' + r.min + '年〜' + r.max + '年です(節入りデータの範囲)。');
    }
  }

  /** 対象日の直前の「節」を返す */
  function findSetsu(y, m, d) {
    const target = jdn(y, m, d);
    let best = null;
    for (const yy of [y - 1, y]) {
      const rows = TABLE[String(yy)];
      if (!rows) continue;
      for (let i = 0; i < 12; i++) {
        const [day, hh, mm] = rows[i];
        const j = jdn(yy, i + 1, day);
        if (j <= target && (best === null || j > best.jdn)) {
          best = { jdn: j, year: yy, monthIndex: i, day, hh, mm, shi: SETSU_SHI[i], name: SETSU_NAME[i] };
        }
      }
    }
    return best;
  }

  /** 対象日より後の、直近の「節」を返す */
  function findNextSetsu(y, m, d) {
    const target = jdn(y, m, d);
    let best = null;
    for (const yy of [y, y + 1]) {
      const rows = TABLE[String(yy)];
      if (!rows) continue;
      for (let i = 0; i < 12; i++) {
        const j = jdn(yy, i + 1, rows[i][0]);
        if (j > target && (best === null || j < best.jdn)) {
          best = { jdn: j, year: yy, monthIndex: i, shi: SETSU_SHI[i], name: SETSU_NAME[i] };
        }
      }
    }
    return best;
  }

  /** 干支の文字から六十干支の通し番号(0始まり)を求める */
  function kanshiIndex(kan, shi) {
    const a = KAN.indexOf(kan);
    const b = SHI.indexOf(shi);
    for (let n = 0; n < 60; n++) if (n % 10 === a && n % 12 === b) return n;
    throw new Error('ありえない干支の組み合わせ: ' + kan + shi);
  }

  /** 立春基準の干支年を返す */
  function kanshiYear(y, m, d) {
    const target = jdn(y, m, d);
    const rows = TABLE[String(y)];
    if (!rows) throw new Error('節入りテーブルに ' + y + ' 年がありません');
    const risshun = jdn(y, 2, rows[1][0]);
    return target >= risshun ? y : y - 1;
  }

  /**
   * 命式を計算する
   * @param {number} y 西暦年 (JST)
   * @param {number} m 月
   * @param {number} d 日
   * @param {object} opts { zokanMonth, zokanYearDay } いずれも 'kantei' | 'A' | 'hongen'
   */
  function meishiki(y, m, d, opts) {
    opts = opts || {};
    const vm = opts.zokanMonth || 'kantei';
    const vyd = opts.zokanYearDay || 'kantei';
    assertInRange(y);

    const setsu = findSetsu(y, m, d);
    if (!setsu) throw new Error('節入りが見つかりません: ' + y + '-' + m + '-' + d);
    const target = jdn(y, m, d);
    const keika = target - setsu.jdn + 1; // 節入り日を1日目とする

    const ky = kanshiYear(y, m, d);
    const yidx = ((ky - 4) % 60 + 60) % 60;
    const ykan = KAN[yidx % 10];
    const yshi = SHI[yidx % 12];

    const mshi = setsu.shi;
    const mkan = KAN[(KAN.indexOf(GOTORA[ykan]) + ((SHI.indexOf(mshi) - 2) % 12 + 12) % 12) % 10];

    const didx = ((target + 49) % 60 + 60) % 60;
    const dkan = KAN[didx % 10];
    const dshi = SHI[didx % 12];

    const zkY = zokan(yshi, keika, vyd);
    const zkM = zokan(mshi, keika, vm);
    const zkD = zokan(dshi, keika, vyd);

    // 陽占(人体図)。配置は kantei-app.com と sanmei-stock.com(高尾式) の出力を実測して合わせた。
    //   目上(北方)=年干 / 中心(中央)=月支の蔵干 / 目下(南方)=月干
    //   社会(東方)=年支の蔵干 / 配偶者(西方)=日支の蔵干
    //   ご先祖様(左上)=伴星。日干と「年干の干合相手」の関係で出す。
    const banseiKan = KANGO[ykan];
    const stars = {
      ancestor: { label: 'ご先祖様', pos: '左上', source: '年干' + ykan + 'の干合', kan: banseiKan, star: shusei(dkan, banseiKan) },
      north:  { label: '目上',   pos: '北方', source: '年干',       kan: ykan, star: shusei(dkan, ykan) },
      center: { label: '中心',   pos: '中央', source: '月支の蔵干', kan: zkM,  star: shusei(dkan, zkM) },
      south:  { label: '目下',   pos: '南方', source: '月干',       kan: mkan, star: shusei(dkan, mkan) },
      east:   { label: '社会',   pos: '東方', source: '年支の蔵干', kan: zkY,  star: shusei(dkan, zkY) },
      west:   { label: '配偶者', pos: '西方', source: '日支の蔵干', kan: zkD,  star: shusei(dkan, zkD) },
    };
    const juseis = {
      early: Object.assign({ label: '初年運', source: '年支', shi: yshi }, jusei(dkan, yshi)),
      mid:   Object.assign({ label: '中年運', source: '月支', shi: mshi }, jusei(dkan, mshi)),
      late:  Object.assign({ label: '晩年運', source: '日支', shi: dshi }, jusei(dkan, dshi)),
    };

    const starSet = [...new Set(Object.values(stars).map((s) => s.star))];
    const juseiSet = [...new Set(Object.values(juseis).map((s) => s.name))];
    const totalEnergy = Object.values(juseis).reduce((a, s) => a + s.energy, 0);

    // 主星の五行分布(参考値)
    const STAR_GOGYO = { 貫索星: '木', 石門星: '木', 鳳閣星: '火', 調舒星: '火', 禄存星: '土', 司禄星: '土', 車騎星: '金', 牽牛星: '金', 龍高星: '水', 玉堂星: '水' };
    const gogyoDist = {};
    for (const s of Object.values(stars)) {
      const g = STAR_GOGYO[s.star];
      gogyoDist[g] = (gogyoDist[g] || 0) + 1;
    }

    return {
      date: y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0'),
      weekday: weekday(y, m, d),
      setsu: {
        name: setsu.name,
        jst: setsu.year + '-' + String(setsu.monthIndex + 1).padStart(2, '0') + '-' + String(setsu.day).padStart(2, '0')
             + ' ' + String(setsu.hh).padStart(2, '0') + ':' + String(setsu.mm).padStart(2, '0'),
        keika,
        isSetsuiriDay: keika === 1,
      },
      pillars: {
        year:  { kan: ykan, shi: yshi, kanshi: ykan + yshi, zokan: zkY, no: yidx + 1, stages: zokanStages(yshi) },
        month: { kan: mkan, shi: mshi, kanshi: mkan + mshi, zokan: zkM, no: kanshiIndex(mkan, mshi) + 1, stages: zokanStages(mshi) },
        day:   { kan: dkan, shi: dshi, kanshi: dkan + dshi, zokan: zkD, no: didx + 1, stages: zokanStages(dshi) },
      },
      stars, starSet,
      jusei: juseis, juseiSet, totalEnergy,
      gogyoDist,
      gogyoVariety: Object.keys(gogyoDist).length,
      // 日柱から出る天中殺(本人の天中殺)。kantei-app では単に「天中殺」と表示される。
      tenchusatsu: TENCHUSATSU[Math.floor(didx / 10)],
      // 本人の天中殺に当たる2支。行運の天中殺判定に使う。
      tenchusatsuShi: TENCHUSATSU_SHI[TENCHUSATSU[Math.floor(didx / 10)]],
      // 各柱が異常干支かどうか(該当すれば '通常' か '暗合')
      ijou: {
        year: ijouKanshi(ykan + yshi, opts.ijouSet),
        month: ijouKanshi(mkan + mshi, opts.ijouSet),
        day: ijouKanshi(dkan + dshi, opts.ijouSet),
      },
      // その日が新月・満月か(月相テーブルを init に渡していない場合は null)
      moon: moonPhaseOn(y, m, d),
      // 時柱。opts.hour を渡したときだけ入る
      hourPillar: (opts.hour == null) ? null : (function () {
        const h = jichu(dkan, opts.hour);
        return Object.assign({}, h, {
          zokan: zokan(h.shi, keika, vyd),
          stages: zokanStages(h.shi),
          no: kanshiIndex(h.kan, h.shi) + 1,
          ijou: ijouKanshi(h.kanshi, opts.ijouSet),
          jusei: jusei(dkan, h.shi).name,
          shusei: shusei(dkan, h.kan),
        });
      })(),
      // 年柱から出る天中殺。kantei-app では「親の天中殺」と表示される。
      tenchusatsuYear: TENCHUSATSU[Math.floor(yidx / 10)],
      nikkanGogyo: GOGYO_KAN[dkan],
      nisshiGogyo: GOGYO_SHI[dshi],
    };
  }

  /**
   * 大運を計算する。
   *
   * 進み方は「陽男陰女は順行、陰男陽女は逆行」(年干の陰陽と性別で決まる)。
   * 立運(何歳から始まるか)は、順行なら次の節入りまでの日数、逆行なら前の節入りからの
   * 日数を 3 で割った商。大運は月柱の次(逆行なら前)の干支から始まり、10年ごとに進む。
   *
   * @param {number} y 生年
   * @param {number} m 生月
   * @param {number} d 生日
   * @param {'male'|'female'} gender 性別
   * @param {object} opts { count: 何本出すか(既定12), zokanMonth, zokanYearDay }
   */
  function daiun(y, m, d, gender, opts) {
    opts = opts || {};
    const count = opts.count || 12;
    const a = meishiki(y, m, d, opts);
    const male = gender === 'male' || gender === 1 || gender === '男' || gender === '男性';
    const yangYear = INYO_KAN[a.pillars.year.kan] === 1;
    const forward = yangYear === male;   // 陽男・陰女 → 順行

    const target = jdn(y, m, d);
    const days = forward
      ? findNextSetsu(y, m, d).jdn - target       // 次の節入りまで
      : target - findSetsu(y, m, d).jdn;          // 前の節入りから
    // 3日を1年に換算する。端数は四捨五入(余り1なら切り捨て、余り2なら切り上げ)。
    // 上限は10歳。節の間隔が32日ある月に生まれると計算上は11歳になるが、
    // 大運が10年周期なのでそこで頭打ちになる(kantei-app の出力で確認)。
    const startAge = Math.min(Math.round(days / 3), 10);

    const mIdx = kanshiIndex(a.pillars.month.kan, a.pillars.month.shi);
    const dkan = a.pillars.day.kan;
    // 大運天中殺 = 大運の支が本人の天中殺の2支に入る期間。連続すると20年続く。
    const honninShi = a.tenchusatsuShi;
    const rows = [];
    for (let i = 0; i < count; i++) {
      const step = forward ? i + 1 : -(i + 1);
      const idx = ((mIdx + step) % 60 + 60) % 60;
      const kan = KAN[idx % 10];
      const shi = SHI[idx % 12];
      const age = startAge + i * 10;
      const ju = jusei(dkan, shi);
      rows.push({
        age, year: y + age, kanshi: kan + shi, kan, shi,
        shusei: shusei(dkan, kan), jusei: ju.name, energy: ju.energy,
        // その大運の干支自体が属する旬から出る天中殺
        tenchusatsu: TENCHUSATSU[Math.floor(idx / 10)],
        // 本人にとってこの10年が大運天中殺かどうか
        isDaiunTenchusatsu: honninShi.indexOf(shi) >= 0,
        ijou: ijouKanshi(kan + shi, opts.ijouSet),
      });
    }
    return { forward, direction: forward ? '順行' : '逆行', startAge, daysToSetsu: days, rows };
  }


  // ===== 位相法(支どうしの関係)=====
  const SHIGO = { 子: '丑', 丑: '子', 寅: '亥', 亥: '寅', 卯: '戌', 戌: '卯', 辰: '酉', 酉: '辰', 巳: '申', 申: '巳', 午: '未', 未: '午' };
  const CHU   = { 子: '午', 午: '子', 丑: '未', 未: '丑', 寅: '申', 申: '寅', 卯: '酉', 酉: '卯', 辰: '戌', 戌: '辰', 巳: '亥', 亥: '巳' };
  const SANGO = [['申', '子', '辰'], ['亥', '卯', '未'], ['寅', '午', '戌'], ['巳', '酉', '丑']];
  const KEI3  = [['寅', '巳', '申'], ['丑', '戌', '未']];
  const KEI2  = [['子', '卯']];
  const JIKEI = ['辰', '午', '酉', '亥'];
  const GAI   = { 子: '未', 未: '子', 丑: '午', 午: '丑', 寅: '巳', 巳: '寅', 卯: '辰', 辰: '卯', 申: '亥', 亥: '申', 酉: '戌', 戌: '酉' };
  const HA    = { 子: '酉', 酉: '子', 丑: '辰', 辰: '丑', 寅: '亥', 亥: '寅', 卯: '午', 午: '卯', 巳: '申', 申: '巳', 未: '戌', 戌: '未' };

  /** 支2つの関係を列挙する */
  function isou(a, b) {
    const out = [];
    if (a === b) out.push(JIKEI.indexOf(a) >= 0 ? '自刑' : '比和');
    if (SHIGO[a] === b) out.push('支合');
    if (CHU[a] === b) out.push('冲');
    for (const g of SANGO) if (a !== b && g.indexOf(a) >= 0 && g.indexOf(b) >= 0) out.push('半会');
    for (const g of KEI3) if (a !== b && g.indexOf(a) >= 0 && g.indexOf(b) >= 0) out.push('刑');
    for (const g of KEI2) if (a !== b && g.indexOf(a) >= 0 && g.indexOf(b) >= 0) out.push('刑');
    if (GAI[a] === b) out.push('害');
    if (HA[a] === b) out.push('破');
    return out;
  }

  /** 命式の中の柱どうしの位相法 */
  function isouInner(a) {
    const y = a.pillars.year.shi, m = a.pillars.month.shi, d = a.pillars.day.shi;
    return {
      '日柱+月柱': isou(d, m),
      '日柱+年柱': isou(d, y),
      '月柱+年柱': isou(m, y),
    };
  }

  // ===== 数理法・八門法 =====
  /**
   * 数理法。28元(天干3つ+各支の蔵干3段すべて)それぞれについて、
   * 命式の3つの支との十二大従星の点数を出して合計する。干ごとに集計したものが数理法、
   * 五行にまとめたものが八門法。kantei-app の出力と一致することを確認済み。
   */
  function suurihou(y, m, d, opts) {
    const a = (opts && opts.meishiki) || meishiki(y, m, d, opts);
    const P = [a.pillars.year, a.pillars.month, a.pillars.day];
    const shis = P.map((p) => p.shi);
    const kans = P.map((p) => p.kan);
    for (const p of P) {
      for (const g of ['初元', '中元', '本元']) if (p.stages[g]) kans.push(p.stages[g]);
    }
    const byKan = {};
    for (const k of KAN) byKan[k] = 0;
    for (const k of kans) {
      let sum = 0;
      for (const sh of shis) sum += jusei(k, sh).energy;
      byKan[k] += sum;
    }
    const byGogyo = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 };
    for (const k of KAN) byGogyo[GOGYO_KAN[k]] += byKan[k];
    const total = Object.keys(byKan).reduce((acc, k) => acc + byKan[k], 0);
    return { byKan, byGogyo, total, genList: kans };
  }

  // ===== 身強・身弱 =====
  // 十二大従星のうち、外に出るエネルギーが大きい側を身強の星として数える。
  const SHINKYO_STARS = ['天将星', '天禄星', '天南星', '天貴星'];
  const SHINJAKU_STARS = ['天報星', '天極星', '天馳星', '天胡星'];
  function shinkyo(a) {
    const names = [a.jusei.early.name, a.jusei.mid.name, a.jusei.late.name];
    const strong = names.filter((n) => SHINKYO_STARS.indexOf(n) >= 0).length;
    const weak = names.filter((n) => SHINJAKU_STARS.indexOf(n) >= 0).length;
    const e = a.totalEnergy;
    return {
      energy: e, strongCount: strong, weakCount: weak,
      hasTenshosei: names.indexOf('天将星') >= 0,
      label: e >= 25 ? '身強' : (e <= 14 ? '身弱' : '中庸'),
    };
  }





  // ===== 行動領域(陰占チャート/宇宙盤)=====
  /**
   * 十二支を円周に置き、命式の3支(年支・月支・日支)を結んだ図のためのデータを返す。
   * 3点が作る三角形の広さが「行動領域の広さ」にあたる。
   * 配置は子を真上に置き、時計回りに30度ずつ(算命学で一般的な方位盤の並び)。
   * sanmei-stock は同じ図をサーバー側で画像にしているが、向きまでは確認できていない。
   */
  // 円周に並ぶのは十二支ではなく「六十干支の通し番号」。1が真上で、時計回りに60まで。
  // 1番が6度おきに進むので、n番の角度は (n-1)×6 度。15番ずつで四つの領域に分かれる。
  //   1〜15 右上=第一領域 / 16〜30 右下=第二領域 / 31〜45 左下=第三領域 / 46〜60 左上=第四領域
  const RYOIKI = [
    { no: 1, name: '第一領域', title: '守備安定', desc: '知性的だが感情的でもある' },
    { no: 2, name: '第二領域', title: '伝達創造', desc: '情的で庶民性がある' },
    { no: 3, name: '第三領域', title: '攻撃開拓', desc: '庶民性と合理性を併せ持つ' },
    { no: 4, name: '第四領域', title: '習得知恵', desc: '合理的、理屈屋、知性豊か' },
  ];
  const ryoikiOf = (no) => RYOIKI[Math.min(3, Math.floor((no - 1) / 15))];

  function koudouRyoiki(a) {
    const src = [
      { label: '年柱', pillar: a.pillars.year },
      { label: '月柱', pillar: a.pillars.month },
      { label: '日柱', pillar: a.pillars.day },
    ];
    const pts = src.map((s) => {
      const no = s.pillar.no;                       // 六十干支の通し番号(1〜60)
      const deg = (no - 1) * 6;                     // 1番が真上、時計回り
      const rad = (deg - 90) * Math.PI / 180;
      return {
        label: s.label, no, kanshi: s.pillar.kanshi, shi: s.pillar.shi, deg,
        ryoiki: ryoikiOf(no), x: Math.cos(rad), y: Math.sin(rad),
      };
    });
    // 三角形の面積(半径1の円に内接する三角形として)
    const area = Math.abs(
      (pts[1].x - pts[0].x) * (pts[2].y - pts[0].y) - (pts[2].x - pts[0].x) * (pts[1].y - pts[0].y)
    ) / 2;
    // 円の面積(π)に対する割合。3点が重なるほど0に近づく
    const ratio = area / Math.PI;
    const unique = [...new Set(pts.map((p) => p.no))];
    // 点どうしの角度差。いちばん離れている側が大きいほど広く散らばっている
    const degs = [...new Set(pts.map((p) => p.deg))].sort((x, y) => x - y);
    let maxGap = 0;
    if (degs.length > 1) {
      for (let i = 0; i < degs.length; i++) {
        const g = (degs[(i + 1) % degs.length] - degs[i] + 360) % 360;
        if (g > maxGap) maxGap = g;
      }
    }
    let label;
    if (unique.length === 1) label = '1点に集中(きわめて狭い)';
    else if (unique.length === 2) label = '2点のみ(狭い)';
    else if (ratio >= 0.35) label = '広い';
    else if (ratio >= 0.18) label = 'ふつう';
    else label = '狭い';
    // 3柱がどの領域に入っているか
    const spread = [...new Set(pts.map((p) => p.ryoiki.name))];
    return { points: pts, area, ratio, unique: unique.length, maxGap, label, ryoiki: RYOIKI, spread };
  }

  // ===== 六親法(家系図)=====
  // 算命学Stock の解説どおりの手順。観測221日 × 家族7人 = 1547項目すべてで出力と一致する。
  //   母親    第一 正母(日干を生じる・陰陽が逆)→ 第二 偏母(陰陽が同じ)→ 定位置
  //   父親    第一「実際に見つかった母の干」の干合相手 → 第二 その恋人の干 → 定位置
  //   兄弟姉妹 代わりの干をとらない。日干のマスは「自分」なので数えない
  //   配偶者   第一 日干の干合相手 → 第二 その恋人の干 → 定位置
  //   子供    「実際に見つかった妻の干」が生じる干。妻と陰陽が同じなら女児、逆なら男児。定位置は使わない
  // 「実際に見つかった干」から次を辿るのが要で、理想の干から辿ると合わない。
  const ROKUSHIN_ORDER = ['母親', '父親', '兄弟', '姉妹', 'パートナー', '男子供', '女子供'];

  // 陰占に決まっている家族の定位置。解説ページの本文と表が食い違っているが、
  // 実例の記述も実際の出力も表のほうと一致する。
  const TEIICHI = {
    母親: ['year', '本元'], 父親: ['year', 'kan'], パートナー: ['day', '本元'],
    家系: ['month', '本元'], 子供: ['month', 'kan'],
  };

  const gyoOf = (i) => Math.floor(i / 2);            // 0木 1火 2土 3金 4水
  const yinOf = (i) => i % 2;                        // 0陽 1陰
  const mkKan = (g, y) => ((((g % 5) + 5) % 5) * 2 + y);
  const kangoOf = (i) => (i + 5) % 10;               // 干合の相手
  const koibitoOf = (i) => { const p = kangoOf(i); return p % 2 === 0 ? p + 1 : p - 1; };

  /**
   * 六親法。日干から家族それぞれの「理想の干」を出し、
   * 陰占(天干と蔵干)の中を第一優先→第二優先→定位置の順に探して「実際の干」を決める。
   * どの段で見つかったかが、そのまま縁の深さの目安になる。
   * 性別では結果が変わらない(女性42日ぶんでも同じ辿り方だった)。
   *
   * @param {object} a meishiki() の結果
   */
  function rokushin(a) {
    const dkan = a.pillars.day.kan;
    const di = KAN.indexOf(dkan);

    // 陰占にある干と、その居場所。日干のマスは「自分」なので家族には数えない。
    const place = {};
    const at = (kan, label) => { if (kan) (place[kan] = place[kan] || []).push(label); };
    const PILLAR_LABEL = { year: '年', month: '月', day: '日' };
    for (const key of ['year', 'month', 'day']) {
      const p = a.pillars[key];
      if (key !== 'day') at(p.kan, PILLAR_LABEL[key] + '干');
      for (const g of ['初元', '中元', '本元']) at(p.stages[g], PILLAR_LABEL[key] + '支の' + g);
    }
    const has = (kan) => Object.prototype.hasOwnProperty.call(place, kan);

    /** 定位置にいる干を返す */
    const teiichiKan = (who) => {
      const spec = TEIICHI[who];
      if (!spec) return null;
      const p = a.pillars[spec[0]];
      return spec[1] === 'kan' ? p.kan : p.stages[spec[1]];
    };

    /** 候補を順に探し、見つからなければ定位置を見る */
    const find = (cands, teiichiFor) => {
      for (let i = 0; i < cands.length; i++) {
        const k = KAN[cands[i]];
        if (has(k)) return { real: k, level: i === 0 ? '理想どおり' : '第二優先' };
      }
      if (teiichiFor) {
        const t = teiichiKan(teiichiFor);
        if (t && has(t)) return { real: t, level: '定位置' };
      }
      return { real: null, level: 'なし' };
    };

    const out = {};
    // 母親:正母がなければ偏母、それも無ければ母の定位置(年支の本元)
    const seibo = mkKan(gyoOf(di) + 4, 1 - yinOf(di));
    const henbo = mkKan(gyoOf(di) + 4, yinOf(di));
    out['母親'] = Object.assign({ ideal: KAN[seibo] }, find([seibo, henbo], '母親'));

    // 父親:実際に見つかった母の干合相手から辿る
    const mBase = out['母親'].real ? KAN.indexOf(out['母親'].real) : null;
    out['父親'] = mBase == null
      ? { ideal: KAN[kangoOf(seibo)], real: null, level: 'なし' }
      : Object.assign({ ideal: KAN[kangoOf(seibo)] }, find([kangoOf(mBase), koibitoOf(mBase)], '父親'));

    // 兄弟姉妹:代わりの干はとらない
    const sis = mkKan(gyoOf(di), 1 - yinOf(di));
    out['兄弟'] = { ideal: dkan, real: has(dkan) ? dkan : null, level: has(dkan) ? '理想どおり' : 'なし' };
    out['姉妹'] = { ideal: KAN[sis], real: has(KAN[sis]) ? KAN[sis] : null, level: has(KAN[sis]) ? '理想どおり' : 'なし' };

    out['パートナー'] = Object.assign({ ideal: KAN[kangoOf(di)] }, find([kangoOf(di), koibitoOf(di)], 'パートナー'));

    // 子供:妻の干が生じる干。
    // 解説では「日干が女性なら日干自身が妻」と書かれているが、実際の出力は性別によらず
    // 配偶者の干から辿っていた(女性42日ぶんで確認)。出力に合わせて配偶者から辿る。
    const wIdeal = kangoOf(di);
    const wReal = out['パートナー'].real ? KAN.indexOf(out['パートナー'].real) : null;
    for (const key of ['男子供', '女子供']) {
      const girl = key === '女子供';
      const ideal = KAN[mkKan(gyoOf(wIdeal) + 1, girl ? yinOf(wIdeal) : 1 - yinOf(wIdeal))];
      out[key] = wReal == null
        ? { ideal, real: null, level: 'なし' }
        : Object.assign({ ideal }, find([mkKan(gyoOf(wReal) + 1, girl ? yinOf(wReal) : 1 - yinOf(wReal))], null));
    }

    const rows = ROKUSHIN_ORDER.map((key) => Object.assign({
      key,
      star: shusei(dkan, out[key].ideal),
      where: out[key].real ? (place[out[key].real] || []).join('・') : '',
    }, out[key]));
    return { self: dkan, rows };
  }

  // ===== 才能(剋数と才能星)=====
  const STAR_GOGYO = { 貫索星: '木', 石門星: '木', 鳳閣星: '火', 調舒星: '火', 禄存星: '土', 司禄星: '土', 車騎星: '金', 牽牛星: '金', 龍高星: '水', 玉堂星: '水' };

  /**
   * 定位置(ていいち)を見る。
   *
   * 人体図の5つのマスには、それぞれ本来その星がいるべき五行が決まっている。
   * そこに合う五行の星が入っていると「定位置にある」といい、その星は陽転しやすいとされる。
   *   縦線(北・南)=精神星、横線(東・中央・西)=現実星
   *   北方=水(龍高星・玉堂星)/南方=火(鳳閣星・調舒星)
   *   東方=木(貫索星・石門星)/中央=土(禄存星・司禄星)/西方=金(車騎星・牽牛星)
   *
   * ご先祖様(伴星)は五つの定位置には含まれないので見ない。
   */
  const TEIICHI_POS = {
    north: { label: '目上(北方)', gogyo: '水', line: '精神', stars: ['龍高星', '玉堂星'] },
    south: { label: '目下(南方)', gogyo: '火', line: '精神', stars: ['鳳閣星', '調舒星'] },
    east: { label: '社会(東方)', gogyo: '木', line: '現実', stars: ['貫索星', '石門星'] },
    center: { label: '中心(中央)', gogyo: '土', line: '現実', stars: ['禄存星', '司禄星'] },
    west: { label: '配偶者(西方)', gogyo: '金', line: '現実', stars: ['車騎星', '牽牛星'] },
  };

  /* ===== 三大教育の干支 =====
     甲戌・己未・癸丑。人を育てる・教育に関わる役割を持つ干支とされる。

     ※出典について:これは口伝として伝わるもので、公開されている資料では
       裏を取れていない。算命学は口伝が本体で、web に出ない内容が多いため
       「見つからない=誤り」とは言えないが、確立した定説として扱うのも違う。
       画面では「口伝」と明記して出すこと。 */
  const KYOUIKU_KANSHI = {
    甲戌: '木の干が、戌という土に根を張る形。まっすぐに育てる。',
    己未: '畑の土が、夏の温かい土に置かれる形。多くを慈しみ育てる。',
    癸丑: '雨露が、冬の終わりの土を潤す形。じっくりと才能を育む。',
  };

  /* ===== 教育の星の組み合わせ =====
     印星(学び・受信)と漏星=食傷(表現・発信)が揃うと、教えることに向くという読み。
       印星  龍高星(偏印・体験で学ぶ)/玉堂星(印綬・書物で学ぶ)
       漏星  鳳閣星(食神・自然体で伝える)/調舒星(傷官・感性で伝える)
     四柱推命では、印星は「学問・インプット」、食傷は「本人から漏れ出るもの=表現・技術」
     とされており、その定義自体は資料で確認できる。
     下の2つの型は口伝として伝わっているもの。残り2通りは、星の意味から導いた派生。 */
  const INSEI = ['龍高星', '玉堂星'];
  const ROSEI = ['鳳閣星', '調舒星'];
  const KYOUIKU_PAIR = {
    '玉堂星+鳳閣星': {
      name: '正統派の教育者', denshou: true,
      note: '玉堂星で仕入れた正しい知識を、鳳閣星でわかりやすく自然に伝える。'
        + '学校の先生にいちばん多いとされる王道の組み合わせ。',
    },
    '龍高星+調舒星': {
      name: '芸術・特殊分野の指導者', denshou: true,
      note: '龍高星の体験から得た知恵と、調舒星の鋭い感性。'
        + '一対一で相手の才能を極限まで引き出す、職人や芸術の世界の師匠の型。',
    },
    '玉堂星+調舒星': {
      name: '専門を掘り下げて伝える型', denshou: false,
      note: '玉堂星の体系的な知識を、調舒星の感性で磨いて出す。'
        + '大人数に教えるより、書く・作る・批評するという形で伝わる。',
    },
    '龍高星+鳳閣星': {
      name: '体験させて伝える型', denshou: false,
      note: '龍高星が自分でやってみたことを、鳳閣星が構えずに渡す。'
        + '座学より、やらせてみて覚えさせる実践型の教え方が合う。',
    },
  };

  /** 人体図の星から、教育の才能の組み合わせを見る */
  function kyouikuSei(a) {
    const has = (list) => list.filter((x) => a.starSet.indexOf(x) >= 0);
    const insei = has(INSEI);
    const rosei = has(ROSEI);
    const pairs = [];
    for (const i of insei) {
      for (const r of rosei) {
        const key = i + '+' + r;
        if (KYOUIKU_PAIR[key]) pairs.push(Object.assign({ key, insei: i, rosei: r }, KYOUIKU_PAIR[key]));
      }
    }
    return {
      insei, rosei, pairs,
      ok: insei.length > 0 && rosei.length > 0,   // 学びと表現が両方そろっているか
    };
  }

  /** 三柱(と時柱)のどこに教育の干支があるかを返す */
  function kyouiku(a) {
    const rows = [];
    const check = (p, label) => {
      if (p && KYOUIKU_KANSHI[p.kanshi]) {
        rows.push({ pos: label, kanshi: p.kanshi, note: KYOUIKU_KANSHI[p.kanshi] });
      }
    };
    check(a.pillars.year, '年柱');
    check(a.pillars.month, '月柱');
    check(a.pillars.day, '日柱');
    check(a.hourPillar, '時柱');
    return {
      rows,
      count: rows.length,
      // 日柱にあるかどうかは別に見る(口伝では日柱が主とされる)
      onDay: rows.some((r) => r.pos === '日柱'),
    };
  }

  /* ===== 吉日・凶日(選日)=====
     節月(立春からの月)と日の干支で機械的に決まるものだけを扱う。
     六曜・不成就日は旧暦が要るので、ここには入れていない。

     出典で確かめた規則
       天赦日   春(立春〜立夏前日)=戊寅、夏=甲午、秋=戊申、冬=甲子
       一粒万倍日 節月ごとに決まった二支(下の表)
       三隣亡   節月1・4・7・10=亥、2・5・8・11=寅、3・6・9・12=午(凶日)
       寅の日   日支が寅/巳の日 日支が巳/己巳の日 干支が己巳 */

  // 節月(1=立春から)に対応する月支
  const SETSUGETSU_SHI = ['寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥', '子', '丑'];

  // 一粒万倍日:節月ごとの二支
  const ICHIRYU = [
    ['丑', '午'], ['酉', '寅'], ['子', '卯'], ['卯', '辰'],
    ['巳', '午'], ['酉', '午'], ['子', '未'], ['卯', '申'],
    ['酉', '午'], ['酉', '戌'], ['亥', '子'], ['卯', '子'],
  ];

  // 三隣亡:節月ごとの支(凶日)
  const SANRINBOU = ['亥', '寅', '午', '亥', '寅', '午', '亥', '寅', '午', '亥', '寅', '午'];

  // 天赦日:季節ごとの干支。季節は立春・立夏・立秋・立冬で切る(=節月で数える)
  const TENSHA = [
    { months: [1, 2, 3], kanshi: '戊寅', season: '春' },
    { months: [4, 5, 6], kanshi: '甲午', season: '夏' },
    { months: [7, 8, 9], kanshi: '戊申', season: '秋' },
    { months: [10, 11, 12], kanshi: '甲子', season: '冬' },
  ];

  /**
   * その日の吉日・凶日を出す。
   * a は meishiki() の返り値。節月は月支から取る(立春からの数え方と一致する)。
   */
  function kichijitsu(a) {
    const setsugetsu = SETSUGETSU_SHI.indexOf(a.pillars.month.shi) + 1;   // 1〜12
    const dayShi = a.pillars.day.shi;
    const dayKanshi = a.pillars.day.kanshi;
    const good = [];
    const bad = [];

    const t = TENSHA.find((x) => x.months.indexOf(setsugetsu) >= 0);
    if (t && t.kanshi === dayKanshi) good.push('天赦日');
    if (setsugetsu >= 1 && ICHIRYU[setsugetsu - 1].indexOf(dayShi) >= 0) good.push('一粒万倍日');
    if (dayShi === '寅') good.push('寅の日');
    if (dayKanshi === '己巳') good.push('己巳の日');
    else if (dayShi === '巳') good.push('巳の日');

    if (setsugetsu >= 1 && SANRINBOU[setsugetsu - 1] === dayShi) bad.push('三隣亡');

    return { setsugetsu, good, bad, all: good.concat(bad) };
  }

  /* ===== 祝日 =====
     tools/gen_holiday_table.py が作った表を引く。1948年(祝日法の施行)以降だけ入っている。
     春分・秋分は天体の運行で決まり、正式には前年に告示される。先の年は計算による見込み。 */
  let HOLI = null;
  function holidayTable() {
    if (HOLI === null) {
      HOLI = (typeof window !== 'undefined' && window.HOLIDAY_TABLE) || false;
    }
    return HOLI;
  }

  /** その年の通日(1月1日=1) */
  function dayOfYear(y, m, d) {
    return jdn(y, m, d) - jdn(y, 1, 1) + 1;
  }

  /**
   * 祝日なら名前を返す。祝日でなければ null。
   * 表を持っていない年(1948年より前など)も null を返す。
   */
  function holiday(y, m, d) {
    const t = holidayTable();
    if (!t) return null;
    const list = t.years[String(y)];
    if (!list) return null;
    const doy = dayOfYear(y, m, d);
    for (const [n, idx] of list) if (n === doy) return t.names[idx];
    return null;
  }

  /** 祝日の表がその年をカバーしているか */
  function hasHolidayData(y) {
    const t = holidayTable();
    return !!(t && y >= t.from && y <= t.to);
  }

  function teiichi(a) {
    const rows = ['north', 'south', 'east', 'center', 'west'].map((k) => {
      const star = a.stars[k].star;
      const t = TEIICHI_POS[k];
      return {
        key: k,
        label: t.label,
        line: t.line,                       // 精神/現実
        gogyo: t.gogyo,                     // その位置の五行
        home: t.stars,                      // そこに入るべき星
        star: star,                         // 実際に入っている星
        starGogyo: STAR_GOGYO[star],
        ok: t.stars.indexOf(star) >= 0,     // 定位置に在るか
      };
    });
    const hit = rows.filter((r) => r.ok);
    return {
      rows: rows,
      count: hit.length,
      keys: hit.map((r) => r.key),
      spirit: rows.filter((r) => r.line === '精神' && r.ok).length,   // 精神星のうち定位置の数
      real: rows.filter((r) => r.line === '現実' && r.ok).length,     // 現実星のうち定位置の数
    };
  }

  /**
   * 才能星を出す。
   * 人体図の主星6つ(伴星を含む)について、その星の五行が他の主星をいくつ剋すかを数え、
   * 一番多いものを才能星とする。sanmei-stock の出力と一致することを確認済み。
   */
  function sainou(a) {
    // 剋数は伴星(ご先祖様)を除いた主星5つの中で数える。
    // 「自分が剋す数」と「自分が剋される数」の合計。
    const keys = ['east', 'west', 'center', 'north', 'south'];   // 東・西・中央・北・南の順
    const list = keys.map((k) => ({ pos: a.stars[k].label, star: a.stars[k].star, gogyo: STAR_GOGYO[a.stars[k].star] }));
    for (const r of list) {
      const gives = list.filter((o) => o !== r && koku(r.gogyo, o.gogyo)).length;
      const takes = list.filter((o) => o !== r && koku(o.gogyo, r.gogyo)).length;
      r.kokusu = gives + takes;
    }
    const candidates = list;
    const max = candidates.reduce((m, r) => Math.max(m, r.kokusu), 0);

    // 型:東・西・中央・北・南(社会・配偶者・中心・目上・目下)の剋数の並びで決まる。
    // sanmei-stock の出力22件を観測して起こした。
    const k = list.map((r) => r.kokusu);   // list は東・西・中央・北・南の順に作ってある
    // 型の判定。sanmei-stock の出力86件を観測して起こし、全件一致を確認した。
    //   全部0 → 無玄 / 全部同じ → 五宮同均
    //   中央が単独で最大 → 了殿
    //   中央以外が単独で最大かつ次点と2以上の差 → 点欽
    //   それ以外 → 順殿
    const kMax = Math.max.apply(null, k);
    const kMin = Math.min.apply(null, k);
    const maxCount = k.filter((x) => x === kMax).length;
    const lower = k.filter((x) => x < kMax);
    const second = lower.length ? Math.max.apply(null, lower) : kMax;
    let kata;
    if (kMax === 0) kata = '無玄の型';
    else if (kMax === kMin) kata = '五宮同均の型';
    else if (k[2] === kMax && maxCount === 1) kata = '了殿の型';
    else if (maxCount === 1 && (kMax - second) >= 2) kata = '点欽の型';
    else kata = '順殿の型';

    // 運:水(龍高・玉堂)と火(鳳閣・調舒)の星がどの方位にあるかで決まる。
    // 中央にあれば北天運。無ければ南天運。東西だけなら東天運、南北だけなら西天運、
    // 両方にまたがれば北天運。sanmei-stock の出力176件で全一致を確認。
    const isThink = (g) => g === '水' || g === '火';
    const eastWest = isThink(list[0].gogyo) || isThink(list[1].gogyo);   // 社会・配偶者
    const center = isThink(list[2].gogyo);
    const northSouth = isThink(list[3].gogyo) || isThink(list[4].gogyo); // 目上・目下
    let un;
    if (center) un = '北天運';
    else if (!eastWest && !northSouth) un = '南天運';
    else if (eastWest && northSouth) un = '北天運';
    else if (eastWest) un = '東天運';
    else un = '西天運';

    return {
      list, max, kata, un,
      kokusuOrder: list.map((r) => ({ pos: r.pos, kokusu: r.kokusu })),
      talent: [...new Set(candidates.filter((r) => r.kokusu === max && max > 0).map((r) => r.star))],
    };
  }

  // ===== 相性の読み方(機械的な固定文)=====
  // AI に書かせると学習データ由来の作り話が混ざるので、判定結果に対する説明は
  // すべてここに固定文として持ち、組み合わせで出す。
  const REL_TEXT = {
    同質: { short: '同', label: '同質', desc: '似た者同士。考え方が近く話が早い。ただし役割がかぶりやすく、同じ場面で競合しやすい' },
    育てる: { short: '育', label: '育てる', desc: 'あなたが相手に与える側。教える・任せる・引き上げると活きる。与えすぎると自分が消耗しやすい' },
    支えられる: { short: '受', label: '支えられる', desc: '相手があなたを支える側。頼る・学ぶと回る。遠慮すると関係が動きにくい' },
    動かす: { short: '推', label: '動かす', desc: 'あなたが相手を引き締める側。依頼や要求が通りやすい。強く出すぎると相手が萎縮する' },
    動かされる: { short: '被', label: '動かされる', desc: '相手があなたを動かす側。課題や刺激をもらえる。圧に感じたら距離と頻度を調整する' },
  };

  // 同じ関係でも、何の間柄かで気をつける所が変わる。かたち別×間柄別の言い方。
  const REL_MODE_TEXT = {
    同質: {
      business: '役割を明確に分けないと同じ仕事を取り合う。担当を先に線引きし、決裁者をどちらかに寄せると回る',
      love: '価値観が近く一緒にいて楽。ただし似た弱点も重なるので、苦手なことを外に任せる工夫がいる',
      friend: '趣味も間合いも合いやすい。長く続くが、同じ相手とばかり組むと世界が広がりにくい',
    },
    育てる: {
      business: 'あなたが出資・発注・教育の側に回ると噛み合う。見返りの取り決めを先に決めないと持ち出しになる',
      love: 'あなたが尽くす形になりやすい。相手の成長は早いが、無理をして与え続けると急に冷める',
      friend: '相談される側になりやすい。頼られるのは信頼の証だが、聞き役だけにならないよう自分の話もする',
    },
    支えられる: {
      business: '相手を後ろ盾や仕入れ元に据えると強い。任せきりにせず、成果と情報はこまめに返すこと',
      love: '甘えられる相手。遠慮すると関係が進まないので、素直に頼るほうがうまくいく',
      friend: '困ったときに助けてくれる相手。もらう一方にならないよう、別の形で返すと長続きする',
    },
    動かす: {
      business: '発注側・管理側が向く。指示は通るが、細かく詰めすぎると相手が動けなくなる',
      love: '主導権を持ちやすい。決めるのが早い反面、相手の言い分を聞く時間を意識して取ること',
      friend: 'あなたが誘って動き出す関係。強く押しすぎると相手が黙って離れるので、断る余地を残す',
    },
    動かされる: {
      business: '相手の要求で鍛えられる。条件と納期を明文化しておかないと、こちらの負担が膨らむ',
      love: '刺激は多いが振り回されやすい。会う頻度と連絡の間隔を自分で決めておくと保つ',
      friend: '背中を押してくれる相手。しんどいときは距離を取ってよい、と最初から思っておく',
    },
  };

  // 天中殺の関わり方も、間柄で意味が変わる
  const TCS_MODE_TEXT = {
    same: {
      business: '同じ時期に同じ弱点が出る。二人だけで事業を回さず、天中殺の年をずらせる第三者を入れておく',
      love: '価値観が深く合う。ただし停滞期が重なるので、その時期に大きな決断をしないと決めておく',
      friend: '一緒にいて楽な相手。落ち込む時期も重なるので、互いに引きずられないよう気をつける',
    },
    bInA: {
      business: '相手に惹かれて条件を甘くしがち。金銭と契約は必ず書面にし、第三者に見てもらうこと',
      love: '強く惹かれるが、振り回されやすい組み合わせ。冷静な時期に条件をすり合わせておく',
      friend: '面白い相手だが、こちらのペースが崩れやすい。付き合う頻度は自分で決めておく',
    },
    aInB: {
      business: '相手があなたに寄りかかってくる。頼られる範囲を先に決めておかないと抱え込む',
      love: '相手から強く求められる関係。応えきれない部分は早めに伝えたほうが後が楽',
      friend: '相手にとって特別な存在になりやすい。無理な期待には線を引いてよい',
    },
    none: {
      business: '天中殺の絡みはない。条件と役割で判断できる、扱いやすい間柄',
      love: '天中殺の絡みはない。過度な引力も反発もなく、落ち着いて進められる',
      friend: '天中殺の絡みはない。距離感を保ちやすく、長く付き合いやすい',
    },
  };
  const AISHO_MODES = [
    { key: 'general', label: '一般' },
    { key: 'business', label: '事業' },
    { key: 'love', label: '恋愛' },
    { key: 'friend', label: '友人' },
  ];
  const TCS_TEXT = {
    same: '2人とも同じ天中殺。運気の谷が同じ時期に来るので、その2年は2人だけで大きな決断をしない方がよい',
    bInA: '相手の日支があなたの天中殺に当たる。惹かれやすい一方で振り回されやすい',
    aInB: 'あなたの日支が相手の天中殺に当たる。相手から見て掴みどころのない存在になりやすい',
    none: '天中殺の重なりはなし',
  };
  const ISOU_TEXT = {
    支合: '引き合う関係。自然と距離が近くなる',
    半会: '同じ方向を向きやすい。共同作業に向く',
    冲: 'ぶつかりやすいが動きも生まれる。緊張感のある関係',
    刑: '摩擦が起きやすい。役割と責任をはっきり決めておくとよい',
    害: '細かいすれ違いが起きやすい。こまめに確認するとよい',
    破: '途中で崩れやすい。節目ごとに合意を取り直すとよい',
    比和: '同じ支どうし。共感しやすいが視点も重なる',
    自刑: '同じ支どうしで、こだわりが強く出やすい組み合わせ',
  };
  // 中心(中央)の星から見た、チームでの役割
  const ROLE_TEXT = {
    貫索星: '一人で貫く力。自分の領域を任せると強い',
    石門星: '人を束ねる力。チームをまとめる位置が向く',
    鳳閣星: '場を和ませ、伝える力。対外的な窓口や広報に向く',
    調舒星: '繊細な感性。細部を詰める、作品や文章を作る役に向く',
    禄存星: '人と縁を集める力。営業や世話役、資金を回す役に向く',
    司禄星: 'こつこつ積み上げる力。実務・管理・経理に向く',
    車騎星: '前に出て動かす力。実行部隊、突破口を開く役に向く',
    牽牛星: '責任感と体面。組織の顔、対外的な折衝や調整に向く',
    龍高星: '好奇心と創造。新しい仕組みを作る、企画・開発に向く',
    玉堂星: '知性と理論。分析、教育、仕組みの言語化に向く',
  };
  const SHINKYO_PAIR = {
    '身強×身強': 'どちらも押しが強い。担当領域を分けると衝突しにくい',
    '身強×身弱': '押す人と受ける人。役割が自然に分かれやすい',
    '身弱×身強': '押す人と受ける人。役割が自然に分かれやすい',
    '身弱×身弱': 'どちらも内に向く。決める人を先に決めておくと進む',
  };

  // ===== 相性 =====
  /**
   * 2人の相性を、事実として計算できる項目だけ並べる。
   * 良し悪しの判定はせず、関係の内容(五行・天中殺・位相法・共通の星など)を返す。
   */
  function aisho(meA, meB, mode) {
    const ka = meA.pillars.day.kan, kb = meB.pillars.day.kan;
    const ea = GOGYO_KAN[ka], eb = GOGYO_KAN[kb];
    let gogyoRel;
    if (ea === eb) gogyoRel = '比和(同じ五行)';
    else if (sho(ea, eb)) gogyoRel = 'あなたが相手を生む(' + ea + '生' + eb + ')';
    else if (sho(eb, ea)) gogyoRel = '相手があなたを生む(' + eb + '生' + ea + ')';
    else if (koku(ea, eb)) gogyoRel = 'あなたが相手を剋す(' + ea + '剋' + eb + ')';
    else gogyoRel = '相手があなたを剋す(' + eb + '剋' + ea + ')';

    const starsA = new Set(Object.keys(meA.stars).map((k) => meA.stars[k].star));
    const starsB = new Set(Object.keys(meB.stars).map((k) => meB.stars[k].star));
    const shared = [...starsA].filter((x) => starsB.has(x));

    const sa = shinkyo(meA), sb = shinkyo(meB);
    const pos = {
      '日柱どうし': isou(meA.pillars.day.shi, meB.pillars.day.shi),
      '月柱どうし': isou(meA.pillars.month.shi, meB.pillars.month.shi),
      '年柱どうし': isou(meA.pillars.year.shi, meB.pillars.year.shi),
    };
    const posAll = [].concat(pos['日柱どうし'], pos['月柱どうし'], pos['年柱どうし']);

    // 五行の関係を「かたち」に落とす
    let kind;
    if (ea === eb) kind = '同質';
    else if (sho(ea, eb)) kind = '育てる';
    else if (sho(eb, ea)) kind = '支えられる';
    else if (koku(ea, eb)) kind = '動かす';
    else kind = '動かされる';

    const tcsKey = (meA.tenchusatsu === meB.tenchusatsu) ? 'same'
      : (meA.tenchusatsuShi.indexOf(meB.pillars.day.shi) >= 0 ? 'bInA'
        : (meB.tenchusatsuShi.indexOf(meA.pillars.day.shi) >= 0 ? 'aInB' : 'none'));
    const uniqIsou = [...new Set(posAll)];
    const skPair = shinkyo(meA).label.replace('中庸', '身強') + '×' + shinkyo(meB).label.replace('中庸', '身強');

    return {
      kind, kindText: REL_TEXT[kind],
      // 読んでそのまま使える説明文(固定文の組み合わせ。AI は使っていない)
      reading: [
        REL_TEXT[kind].desc,
        // 間柄を選んでいるときは、その間柄ならではの見どころを足す
        (mode && mode !== 'general' && REL_MODE_TEXT[kind][mode]) || '',
        TCS_TEXT[tcsKey],
        (mode && mode !== 'general' && TCS_MODE_TEXT[tcsKey] && TCS_MODE_TEXT[tcsKey][mode]) || '',
        SHINKYO_PAIR[skPair] || '',
        uniqIsou.map((x) => ISOU_TEXT[x]).filter(Boolean).join('。'),
        shared.length ? '「' + shared.join('・') + '」を2人とも持っているので、その面では話が通じやすい'
          : '持ち味が重ならないので、補い合う形になりやすい',
      ].filter(Boolean),
      gogyo: { a: ea, b: eb, relation: gogyoRel, same: ea === eb },
      tenchusatsu: {
        a: meA.tenchusatsu, b: meB.tenchusatsu, same: meA.tenchusatsu === meB.tenchusatsu,
        // 相手の日支が自分の天中殺に入っているか
        bInA: meA.tenchusatsuShi.indexOf(meB.pillars.day.shi) >= 0,
        aInB: meB.tenchusatsuShi.indexOf(meA.pillars.day.shi) >= 0,
      },
      sharedStars: shared,
      shinkyo: { a: sa, b: sb },
      isou: pos,
      isouSummary: posAll.length ? [...new Set(posAll)].join('・') : 'なし',
      kango: KANGO[ka] === kb,   // 日干どうしが干合するか
    };
  }

  /**
   * 行運(年運・月運・日運・時運)を出す。
   *
   * 本人の日干を基準に、巡ってきた干支との関係で主星・従星を出す。
   * 天中殺は「本人の天中殺に当たる2支が巡ってきたとき」に成立する。
   * 年運は干支年(立春区切り)、月運は節入り区切りで見る。
   *
   * @param {object} birth {y, m, d} 生年月日
   * @param {object} at {y, m, d, hour} 見たい日時(hour は省略可)
   */
  function gyoun(birth, at, opts) {
    opts = opts || {};
    const me = meishiki(birth.y, birth.m, birth.d, opts);
    const target = meishiki(at.y, at.m, at.d, opts);
    const dkan = me.pillars.day.kan;      // 星は本人の日干から見る
    const mine = me.tenchusatsuShi;

    const row = (label, kan, shi) => {
      const ju = jusei(dkan, shi);
      return {
        label, kanshi: kan + shi, kan, shi,
        shusei: shusei(dkan, kan),
        jusei: ju.name,
        energy: ju.energy,
        unsei: ju.unsei,
        isTenchusatsu: mine.indexOf(shi) >= 0,
        ijou: ijouKanshi(kan + shi, opts.ijouSet),
      };
    };

    const out = {
      tenchusatsu: me.tenchusatsu,
      shi: mine,
      nikkan: dkan,
      moon: moonPhaseOn(at.y, at.m, at.d),
      year: row('年運', target.pillars.year.kan, target.pillars.year.shi),
      month: row('月運', target.pillars.month.kan, target.pillars.month.shi),
      day: row('日運', target.pillars.day.kan, target.pillars.day.shi),
      hour: null,
    };
    if (at.hour != null) {
      const h = jichu(target.pillars.day.kan, at.hour);
      out.hour = row('時運', h.kan, h.shi);
    }
    return out;
  }

  // 時運の12コマ。jichu の区切りに合わせた代表の時刻と範囲。
  const HOUR_SLOTS = [
    [23, '23:00〜00:59'], [1, '01:00〜02:59'], [3, '03:00〜04:59'], [5, '05:00〜06:59'],
    [7, '07:00〜08:59'], [9, '09:00〜10:59'], [11, '11:00〜12:59'], [13, '13:00〜14:59'],
    [15, '15:00〜16:59'], [17, '17:00〜18:59'], [19, '19:00〜20:59'], [21, '21:00〜22:59'],
  ];

  /**
   * 行運を一覧で返す。大運と同じように並べて追えるようにするためのもの。
   *
   * @param {{y:number,m:number,d:number}} birth 本人の生年月日
   * @param {'year'|'month'|'day'|'hour'} unit どの単位で並べるか
   * @param {{y:number,m:number,d:number}} anchor 起点。年運はこの年から、月運はこの年の12か月、
   *                                              日運はこの月、時運はこの日を並べる
   * @param {object} [opts] count で年運の年数を指定(既定12)。
   *                        now を渡すと「いまの行」の印はそちらと突き合わせる(前後に送ったとき用)
   */
  function gyounList(birth, unit, anchor, opts) {
    opts = opts || {};
    const now = opts.now || anchor;
    const me = meishiki(birth.y, birth.m, birth.d, opts);
    const dkan = me.pillars.day.kan;
    const mine = me.tenchusatsuShi;

    const row = (label, kan, shi, extra) => {
      const ju = jusei(dkan, shi);
      return Object.assign({
        label, kanshi: kan + shi, kan, shi,
        shusei: shusei(dkan, kan),
        jusei: ju.name, energy: ju.energy, unsei: ju.unsei,
        isTenchusatsu: mine.indexOf(shi) >= 0,
        ijou: ijouKanshi(kan + shi, opts.ijouSet),
      }, extra || {});
    };
    /** その年の誕生日を迎えたあとの年齢 */
    const ageAt = (y) => y - birth.y;
    const lim = range();
    const clamp = (y) => Math.min(lim.max, Math.max(lim.min, y));

    const rows = [];
    if (unit === 'year') {
      // 年干支は立春で替わるので、立春を過ぎた6月で読む
      const count = opts.count || 12;
      const from = clamp(anchor.y);
      const to = clamp(from + count - 1);
      for (let y = from; y <= to; y++) {
        const a = meishiki(y, 6, 15, opts);
        rows.push(row(y + '年', a.pillars.year.kan, a.pillars.year.shi,
          { year: y, age: ageAt(y), isNow: y === now.y }));
      }
    } else if (unit === 'month') {
      // 月干支は節入りで替わる。各月の15日はその節の中に確実に入る
      for (let m = 1; m <= 12; m++) {
        const a = meishiki(anchor.y, m, 15, opts);
        rows.push(row(anchor.y + '年' + m + '月', a.pillars.month.kan, a.pillars.month.shi,
          { year: anchor.y, month: m, isNow: anchor.y === now.y && m === now.m }));
      }
    } else if (unit === 'day') {
      const last = new Date(Date.UTC(anchor.y, anchor.m, 0)).getUTCDate();
      const sameMonth = anchor.y === now.y && anchor.m === now.m;
      for (let d = 1; d <= last; d++) {
        const a = meishiki(anchor.y, anchor.m, d, opts);
        const ki = kichijitsu(a);
        rows.push(row(anchor.y + '年' + anchor.m + '月' + d + '日(' + a.weekday + ')',
          a.pillars.day.kan, a.pillars.day.shi,
          {
            year: anchor.y, month: anchor.m, day: d, isNow: sameMonth && d === now.d, moon: a.moon,
            kichi: ki.good, kyou: ki.bad,
            holiday: holiday(anchor.y, anchor.m, d),
          }));
      }
    } else if (unit === 'hour') {
      const a = meishiki(anchor.y, anchor.m, anchor.d, opts);
      for (const [h, range] of HOUR_SLOTS) {
        const j = jichu(a.pillars.day.kan, h);
        rows.push(row(j.shi + '(' + range + ')', j.kan, j.shi, { hour: h, range }));
      }
    }
    return { unit, rows, nikkan: dkan, tenchusatsu: me.tenchusatsu, shi: mine, anchor };
  }

  /**
   * 条件に合う日を検索する
   * @param {string} from 'YYYY-MM-DD'
   * @param {string} to   'YYYY-MM-DD'
   * @param {object} cond {
   *   tenchusatsu: string[]|null,       // いずれかに一致 (OR)
   *   nikkanGogyo: string[]|null,       // 日干の五行がいずれかに一致 (OR)
   *   nisshiGogyo: string[]|null,       // 日支の五行がいずれかに一致 (OR)
   *   requireShusei: string[],          // 主星に全部含む (AND)
   *   requireJuseiAny: string[],        // 従星にいずれか含む (OR)
   *   minEnergy: number|null,           // 従星エネルギー合計の下限
   *   robust: boolean                   // trueなら蔵干4パターン全部で成立する日だけ返す
   * }
   */
  function search(from, to, cond, opts) {
    cond = cond || {};
    opts = opts || {};
    const [fy, fm, fd] = from.split('-').map(Number);
    const [ty, tm, td] = to.split('-').map(Number);
    const j0 = jdn(fy, fm, fd);
    const j1 = jdn(ty, tm, td);
    const lim = range();

    // 既定は kantei(実測に合わせた表)。robust のときは他流派の区分でも成立するものだけに絞る。
    // ここを 'A' のままにしていたため、検索だけ別の蔵干で計算されるバグがあった。
    const variants = cond.robust
      ? [['kantei', 'kantei'], ['A', 'A'], ['kantei', 'hongen'], ['A', 'hongen']]
      : [[opts.zokanMonth || 'kantei', opts.zokanYearDay || 'kantei']];

    const hits = [];
    const maxHits = opts.maxHits || 500;
    for (let j = j0; j <= j1; j++) {
      const [y, m, d] = fromJdn(j);
      if (y < lim.min || y > lim.max) continue;
      let ok = true;
      let base = null;
      for (const [vm, vyd] of variants) {
        const a = meishiki(y, m, d, { zokanMonth: vm, zokanYearDay: vyd });
        if (base === null) base = a;
        if (!matches(a, cond)) { ok = false; break; }
      }
      if (ok && base) {
        hits.push(base);
        if (hits.length >= maxHits) break;
      }
    }
    return hits;
  }

  function matches(a, cond) {
    if (cond.tenchusatsu && cond.tenchusatsu.length && !cond.tenchusatsu.includes(a.tenchusatsu)) return false;
    if (cond.nikkanGogyo && cond.nikkanGogyo.length && !cond.nikkanGogyo.includes(a.nikkanGogyo)) return false;
    if (cond.nisshiGogyo && cond.nisshiGogyo.length && !cond.nisshiGogyo.includes(a.nisshiGogyo)) return false;
    if (cond.requireShusei && cond.requireShusei.length) {
      for (const s of cond.requireShusei) if (!a.starSet.includes(s)) return false;
    }
    if (cond.requireJuseiAny && cond.requireJuseiAny.length) {
      if (!cond.requireJuseiAny.some((s) => a.juseiSet.includes(s))) return false;
    }
    if (cond.minEnergy != null && a.totalEnergy < cond.minEnergy) return false;
    return true;
  }

  /** ある日について、指定条件のうち何が欠けているかを返す(惜しい日の説明用) */
  function missing(a, cond) {
    const miss = [];
    if (cond.tenchusatsu && cond.tenchusatsu.length && !cond.tenchusatsu.includes(a.tenchusatsu)) miss.push('天中殺');
    if (cond.nikkanGogyo && cond.nikkanGogyo.length && !cond.nikkanGogyo.includes(a.nikkanGogyo)) miss.push('日干の五行');
    if (cond.nisshiGogyo && cond.nisshiGogyo.length && !cond.nisshiGogyo.includes(a.nisshiGogyo)) miss.push('日支の五行');
    for (const s of (cond.requireShusei || [])) if (!a.starSet.includes(s)) miss.push(s);
    if (cond.requireJuseiAny && cond.requireJuseiAny.length && !cond.requireJuseiAny.some((s) => a.juseiSet.includes(s))) miss.push('従星');
    if (cond.minEnergy != null && a.totalEnergy < cond.minEnergy) miss.push('エネルギー');
    return miss;
  }

  /** 条件を1つだけ落とせば成立する「惜しい日」を探す */
  function searchNearMiss(from, to, cond, opts) {
    opts = opts || {};
    const [fy, fm, fd] = from.split('-').map(Number);
    const [ty, tm, td] = to.split('-').map(Number);
    const lim = range();
    const out = [];
    for (let j = jdn(fy, fm, fd); j <= jdn(ty, tm, td); j++) {
      const [y, m, d] = fromJdn(j);
      if (y < lim.min || y > lim.max) continue;
      const a = meishiki(y, m, d, { zokanMonth: opts.zokanMonth || 'kantei', zokanYearDay: opts.zokanYearDay || 'kantei' });
      const miss = missing(a, cond);
      if (miss.length === 1) out.push({ meishiki: a, missing: miss[0] });
      if (out.length >= (opts.maxHits || 200)) break;
    }
    return out;
  }

  return {
    init, range, diffDays, meishiki, daiun, gyoun, gyounList, moonPhaseOn, moonPhases, ijouKanshi, jichu,
    suurihou, isou, isouInner, shinkyo, aisho, sainou, rokushin, koudouRyoiki, teiichi,
    holiday, hasHolidayData, kichijitsu, kyouiku, kyouikuSei,
    search, searchNearMiss, missing, matches,
    KAN, SHI, SHUSEI_LIST, JUSEI_LIST, TENCHUSATSU,
    GOGYO_KAN, GOGYO_SHI, JU_MAP, TENCHUSATSU_SHI, IJOU_KANSHI, KANGO, TEIICHI_POS, KYOUIKU_KANSHI,
    REL_TEXT, ISOU_TEXT, ROLE_TEXT, TCS_TEXT, REL_MODE_TEXT, TCS_MODE_TEXT, AISHO_MODES,
    _internal: { jdn, fromJdn, weekday, shusei, jusei, zokan, zokanStages, findSetsu, findNextSetsu, kanshiIndex, kanshiYear },
  };
})();

if (typeof module !== 'undefined' && module.exports) module.exports = SANMEI;
Share Note for Obsidian 🌓