やってみた 2026年10月30日

紫微斗数の命盤エンジン ziwei-doushu で出生時刻の境界を突いてみた。同梱Demoはエンジンの真太陽時を通っていなかった

XECIN TypeScriptNext.js日時処理テスト

GitHubのトレンドを眺めていたら、5位に Renhuai123/ziwei-doushu が入っていました。スターは10,757、その日だけで1,146増えています。説明は「紫微斗数开源排盘引擎」。紫微斗数(中国の占術のひとつ)で使う命盤を、生年月日と生まれた時刻から組み立てるエンジンです。

占いそのものに詳しいわけではありません。気になったのは、READMEの「エンジンでできること」の表のほうでした。真太陽時の補正(出生地の経度と均時差)、中国の1986〜1991年の夏時間、23時台の「晩子時」をどちらの日に数えるか、閏月の扱い。ぜんぶ日時処理の話なんですよね。

フロントエンドをやっていると、生年月日の入力フォームは何度も作ります。そして日時のバグはだいたい境界に住んでいます。というわけで、境界を突いてみました。

まず動かす:npm install 10秒、回帰テスト10本が21秒

試した環境は使い捨てのDockerコンテナ(node:22-bookworm、Node v22.23.1 / npm 10.9.8 / Debian 12)です。対象は main の ff9a1470eb(2026-10-27のコミット「海外时区真太阳时 + 夏令时钟表读数换成标准时间」)。READMEのクイックスタートどおりに入れました。

# clone と依存の導入(READMEの手順そのまま)
$ git clone --depth 1 https://github.com/Renhuai123/ziwei-doushu.git   # 1,514ms
$ npm install --no-audit --no-fund                                   # 9,986ms
$ du -sh node_modules
430M
# next=16.3.8 iztro=2.6.1 lunar=1.7.7 tsx=4.23.15

# 回帰テストと型チェック
$ npm test            # rc=0 / 20,888ms / 出力58行
✅ 排盘完整性回归全过 (768 盘 × 7 条紫微铁律)
  ✅ 1930-1995 逐日差分全一致(比对 24106 天,其中 5802 天属夏令时)
  ✅ ★1988 起始日:金标准 4/10(IANA 记 4/17,按铁律取金标准)
[sizhu-lichun-boundary] 10 项断言全部通过
iztro hour-index boundary regression passed
solar date existence regression passed
$ npm run typecheck   # rc=0 / 2,941ms

APIキーは要りません。READMEにも「开源版不含后端 API 路由」「不需要任何 API Key」とあって、命盤の計算はブラウザの中で完結します。AIの解読(/api/interpret)は自分で実装する前提で、オープンソース版には入っていません。

テストの出力で目が止まったのは1988年の行です。中国の夏時間の開始日を、IANA(タイムゾーンのデータベース)は4月17日、このプロジェクトの「金標準」は4月10日としていて、テストは金標準を採ると明記しています。この話は後半でもう一度出てきます。

依存の中に iztro(2.6.1)が入っているのもポイントでした。命盤の組み立て自体は既存の紫微斗数ライブラリ iztro に任せて、その手前の「入力された時刻をどう解釈するか」を自前で厚くしている、という作りに見えます。

日本の出生地を入れてみる(10/27 に入った utcOffsetMinutes)

真太陽時の補正は lib/ziwei/true-solar-time.ts の calculateTrueSolarTime() です。経度差に均時差(季節で±十数分ずれる太陽の進み遅れ)を足して、時辰(2時間ごとの十二支)を決めます。

10月27日のコミットで、ここに utcOffsetMinutes という引数が入りました。省略すると北京時間(480分=東経120度が基準)として計算します。日本の時刻を入れるなら540(東経135度が基準)を渡すわけですね。東京・札幌・那覇で、渡した場合と省略した場合を比べました(経度は自分で入れた値です)。

# 自作スクリプト(tsx)で calculateTrueSolarTime() を直接呼んだ結果
2026-11-3 12:45 Tokyo lon=139.6917 | offset540: lonCorr=18.77 eot=16.36 -> 13:20 未 | offset省略(480): lonCorr=78.77 -> 14:20 未
2026-11-3 12:45 Sapporo lon=141.3544 | offset540: lonCorr=25.42 eot=16.36 -> 13:26 未 | offset省略(480): lonCorr=85.42 -> 14:26 未
2026-11-3 12:45 Naha lon=127.6809 | offset540: lonCorr=-29.28 eot=16.36 -> 12:32 午 | offset省略(480): lonCorr=30.72 -> 13:32 未
2026-2-11 13:30 Tokyo lon=139.6917 | offset540: lonCorr=18.77 eot=-14.2 -> 13:34 未 | offset省略(480): lonCorr=78.77 -> 14:34 未
2026-2-11 13:30 Sapporo lon=141.3544 | offset540: lonCorr=25.42 eot=-14.2 -> 13:41 未 | offset省略(480): lonCorr=85.42 -> 14:41 未
2026-2-11 13:30 Naha lon=127.6809 | offset540: lonCorr=-29.28 eot=-14.2 -> 12:46 午 | offset省略(480): lonCorr=30.72 -> 13:46 未

那覇がいちばん分かりやすいです。2月11日13:30生まれは、540を渡すと12:46で「午」、省略すると13:46で「未」。省略したときは経度補正が+30.72分になっていて、ちょうど60分ずれています。基準の子午線が東経120度のままだからですね。

東京と札幌はこの時刻だとどちらも「未」でした。ただ補正量は毎回60分違うので、時辰の境目の近くで生まれた人は結果が変わります。引数ひとつの話ですが、国外の時刻を扱うなら必ず渡す、と覚えておくのがよさそうです。

Demoのフォームは、エンジンの真太陽時を通っていなかった

ここからが本題です。リポジトリには Next.js の画面(/chart)も入っていて、npm run dev で動きます。READMEは「Demo」と断ったうえで、出生地を選ぶと真太陽時で時辰を補正すると書いています。では、フォームはどうやって時辰を決めているのか。先にコードを見てください。

// components/BirthForm.tsx(34〜41行目)
/** 根据北京时间 + 经度计算真太阳时时辰支 (0-11) */
function calcTrueSolarBranch(clockHour: number, clockMinute: number, longitude: number): number {
  const clockMins = clockHour * 60 + clockMinute;
  const offset = (longitude - 120) * 4;
  const solar = ((clockMins + offset) % 1440 + 1440) % 1440;
  if (solar >= 1380 || solar < 60) return 0;
  return Math.floor((solar - 60) / 120) + 1;
}

// app/chart/page.tsx(36行目): フォームの値をそのまま generateChart() へ
<BirthForm onSubmit={(info: BirthInfo) => setChart(generateChart(info))} />

// ↑ ここは改善の余地ありそうです。均時差を足しておらず、日付をまたいでも日付は入力のまま。
//   lib/ziwei/true-solar-time.ts を import しているのは、リポジトリ内では
//   回帰テスト(clock-adjust.test.ts)だけでした(grep で確認)。

フォームは経度差だけで時辰を決めていて、エンジン側の calculateTrueSolarTime() を呼んでいません。読んだだけだと「均時差の十数分くらい」と流しそうになるので、数えました。2026年の毎日、0:00から23:59までの毎分(525,600通り)で、フォームと同じ式とエンジンの関数の時辰を比べています。都市の経度は Demo の都市表(lib/ziwei/cities.ts)の値です。

都市(都市表の経度)時辰が食い違った分(1年)真太陽時が0時をまたぎ、エンジンだと排盤日が前日・翌日に動く分(1年)
上海(121.5°E)31,713分(6.03%)2,487分
ハルビン(126.5°E)31,716分(6.03%)9,310分
ウルムチ(87.6°E)31,596分(6.01%)25,578分(1日平均で約70分)

時辰の食い違いはどの都市でも約6%で、これは均時差の分です。12:00時点の均時差は、この年だと11月1日の+16.38分から2月14日の-14.26分まで動いていました。たとえば上海の11月1日0:38は、フォームの式だと「子」、エンジンだと1:00で「丑」になります。

右の列はもう少し深刻で、真太陽時が日付をまたいだときの話です。経度が大きく西にずれるウルムチだと、補正が2時間前後のマイナスになるので、0時台を中心に、1日あたり平均約70分ぶんの時刻が前日扱いに戻ります。

実際に画面でやってみました。ブラウザは、ホストのものを使わずコンテナ内の Playwright(1.56.1)で操作・撮影しています。新疆ウイグル自治区のウルムチ、2026年10月30日の0:30を入れたところです。

Demoのフォーム。出生地にウルムチを選ぶと「経度 87.6°E・時差 -130 分钟」と出て、0時30分の真太陽時は亥時と表示されている

フォームは「時差 -130 分钟」「真太阳时 → 亥时」と表示し、送信内容の確認欄は「2026年10月30日・乌鲁木齐・亥时・男」です。時辰は亥なのに、日付は30日のまま。起盤するとこうなりました。

Demoで起盤した命盤。中央に命宮 亥・身宮 酉・木三局・2026·9·21とあり、紫微は子女宮(丙申)に入っている

中央の表示は命宮 亥・身宮 酉・木三局で、農暦は2026年9月21日。紫微は申の宮(子女)に入っています。同じ入力をエンジンの関数に通すと、真太陽時は前日の22:36(亥)で、排盤日は10月29日に戻ります。その日で命盤を作ると、農暦は9月20日、紫微は未の宮でした。命宮や五行局は同じでも、紫微の位置が申から未へ1宮ずれます。

ついでに、出生地を選ばずに23:30を入れた場合も見ました。READMEのエンジンの表には「晚子时换日:23:00–23:59 按次日排盘」とあって、エンジンの resolveZiweiDayOffset() も既定は翌日扱い(next-day)です。

入力Demoの画面(観測)エンジンの関数を通した場合(自作スクリプト)
2026-10-30 0:30・ウルムチ亥時・10月30日のまま/農暦9月21日・紫微は申前日22:36(亥)→ 10月29日/農暦9月20日・紫微は未
2026-10-30 23:30・出生地なし子時・10月30日のまま/農暦9月21日・紫微は申next-day で10月31日/農暦9月22日・紫微は亥(current-day なら申)

Demoの23:30は、エンジンでいう current-day(当日扱い)と同じ結果でした。READMEの表にある「翌日で排盤」は、エンジンの関数を通したときの話で、Demoの画面の挙動ではない、ということになります。

公平のために書いておくと、画面そのものに「本页为引擎 Demo」と書かれていて、READMEにも「界面是一份 Demo」という節があり、オンライン版のUIは別物だと説明しています。なのでこれは「Demoが間違っている」というより、Demoをそのまま自分のアプリの土台にすると、エンジンの売りの部分を通らない、という注意書きに近いです。正直なところ、自分が土台にするなら真っ先にここを差し替えます。

ハマったところ:127.0.0.1で開くとフォームが動かない

この画面を撮るまでに一度つまずきました。コンテナ内のブラウザで http://127.0.0.1:3000/chart を開くと、ページは表示されるのに、時刻を23時にしても表示は「真太阳时 → 辰时(07:00-09:00)」のまま。送信ボタンを押すとURLの末尾に ? が付くだけで、命盤が出ません。

# /work/dev.log(npx next dev --hostname 0.0.0.0 --port 3000 のログ)
  The following mandatory changes were made to your tsconfig.json:
  	- jsx was set to react-jsx (next.js uses the React automatic runtime)
Cross-origin access to Next.js dev resources is blocked by default for safety.

To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server:
 GET /chart 200 in 24ms (next.js: 1.6ms, application-code: 23ms)
 GET /chart? 200 in 24ms (next.js: 1.7ms, application-code: 22ms)

Next.js 16 の開発サーバーが、127.0.0.1 からの dev 用リソースの読み込みを止めていて、画面がハイドレーションされていなかったんですよね。サーバーが返したHTMLのフォームがそのまま送信されていたので GET /chart? が並んでいます。開く先を http://localhost:3000/chart に変えただけで動きました。allowedDevOrigins を足す手もありますが、検証対象の設定を変えたくなかったのでURLのほうで合わせています。

ログの「Cross-origin access … is blocked」の行が、そのまま答えだったわけです。もうひとつ、next dev は起動時に tsconfig.json の jsx を書き換えます。コンテナの中なので実害はありませんが、手元で試すと差分が出るので気をつけてください。

夏時間は「判定だけ」、存在しない日付は「止める」

残りの境界も見ておきます。中国の夏時間は、10月27日のREADMEの補足で「エンジンは判定するだけで、自動では差し引かない」と書き直されていました(それまでの表は「自動扣除」となっていて誤りだった、と作者が訂正しています)。差し引くのは clock-adjust.ts で、利用者が自分で押すボタンにするのが推奨だそうです。

# 夏時間:判定と、時計の読みを標準時へ直す関数
isChinaDST(1988,7,1)=true range1988={"start":"4 月 10 日","end":"9 月 11 日"} isChinaDST(1992,7,1)=false
delta(540->480)=-60 13:30->{"clockHour":"12","clockMinute":"30"} 00:30->null
  1988-7-1 clock 13:30 (未): 命宮=亥 身宮=丑 水二局
  1988-7-1 standard 12:30 (午): 命宮=子 身宮=子 金四局

# Node の Intl(tzdata 2026a / ICU 78.2)で Asia/Shanghai の1988年4月を見る
1988-04-10 06:00Z -> 10/04/1988, 14:00:00 GMT+8
1988-04-12 06:00Z -> 12/04/1988, 14:00:00 GMT+8
1988-04-17 06:00Z -> 17/04/1988, 15:00:00 GMT+9

# 存在しない日付:iztro に直接渡す vs generateChart()
iztro 2026-2-30: solarDate=2026-2-30 lunar=二〇二六年正月十四 | 2026-3-2: lunar=二〇二六年正月十四 | same(lunar+palaces)=true
generateChart: RangeError: solar date does not exist: 2026-2-30

1988年7月1日の13:30(夏時間の時計の読み)をそのまま使うと命宮は亥、1時間戻した12:30だと子で、五行局まで変わります。0:30から1時間戻すと前日になるので、関数は null を返して「日付をまたぐ調整は人に任せる」という作りでした。ここは好みが分かれるところですが、自動で日付まで動かさないのは誠実だと思います。

気になったのは真ん中のブロックです。Node の Intl(IANAのデータ)だと1988年4月10日と12日は+8時間のままで、17日には+9時間になっていました。エンジンの表は10日から夏時間です。どちらが史実に合うのかは自分には判断できませんが、Intl で夏時間を自動判定している既存コードとこのエンジンを混ぜると、テストの記述どおりなら4月10日〜16日の7日間に生まれた人だけ1時間ずれる、ということは言えます。テストに「IANA 记 4/17,按铁律取金标准」と書いてあるので、作者は承知の上ですね。

最後の2行は好印象でした。iztro に2月30日を渡すとエラーにならず、3月2日と同じ命盤(農暦も宮の中身も一致)を返します。generateChart() はその手前で RangeError を投げて止めます。ソースのコメントにも、iztro は存在しない日付を翌月に送ってしまうので弾く、と理由が書いてありました。フォーム側にも同じ日付チェックがあります。

試してみての所感

コンテナを立ててから壊すまで424秒。npm install 10秒、テスト21秒、next dev もすぐ上がって、触る分には本当に手軽でした。テストが「768盤」「1930〜1995年を逐日比較」と境界を真面目に押さえているのも、日時処理を書く側として参考になります。

一方で、エンジンの関数と同梱Demoの画面は別物として見たほうがいい、というのが今回いちばんの収穫でした。READMEの表に並んでいる真太陽時(均時差つき)・日付またぎ・晩子時の換日は、lib/ziwei/ を自分で呼んだときに効くもので、Demoのフォームはそこを通りません。公式ドキュメントにはこう書いてあるんですが、実際やってみると画面とエンジンで答えが違う、というよくあるパターンですね。

自分が誕生日入力のある画面を作るなら、時辰の計算は calculateTrueSolarTime() と resolveZiweiDayOffset() に寄せて、国外の時刻なら utcOffsetMinutes を必ず渡し、夏時間の調整は利用者に確認させる形にすると思います。1988年4月の7日間のような、データの出どころで答えが割れる場所は、画面に注記を出すのが無難そうです。

日時の境界を手早く確かめる方法として、毎分を総当たりで比べるのはかなり効きました。もっと良い方法があったら教えてください。