AI Dev Lab
AspectChart

AspectChart ができるまで — Meeus 式で七天体を計算する

ホロスコープ作成ツールの開発ログ。 Meeus の近似式とケプラー方程式のニュートン法反復で七天体の黄経を求め、 12 サインの分割・アスペクトの許容度・円形チャートの SVG 描画までを記録しています。

ホロスコープの「アスペクト」を調べようとすると、たいてい壁にぶつかります。

無料サイトは出生図の絵は出してくれるのに、アスペクト(天体同士の角度)の一覧は有料。あるいは会員登録が必要。そうでなければ、そもそもアスペクトという概念に触れていない。

一方で、アスペクトこそが西洋占星術の面白いところです。「太陽が牡羊座」だけなら 12 通りしかありませんが、7 天体の組み合わせと角度まで見ると、途端に個別性が出てきます。

AspectChart は、生年月日を入れると 7 天体の位置とアスペクトを全部出す、無料・登録不要のツールです。ただ作るにあたって、避けて通れない問題がひとつありました。

天体の位置を、どうやって計算するのか。


#🎯 天文暦を持たずに惑星の位置を出す

普通、天体位置は NASA JPL の DE430 のような天文暦データを引きます。精度は極めて高い代わりに、データファイルが数十 MB〜数百 MB。ブラウザで完結させたいサービスには載せられません。

そこで、天文計算の古典である **Jean Meeus『Astronomical Algorithms』**の簡略式を実装することにしました。数式だけで位置を出すアプローチです。

精度は用途で割り切りました。

天体手法誤差の目安
太陽Meeus 低精度式約 0.01 度
Meeus 主要 6 項約 0.3 度
水金火木土簡略 Kepler + 地球オフセット度のオーダー

占星術で使うアスペクトの許容範囲(オーブ)は 4〜8 度あるので、この精度で判定は安定します。必要な精度を、用途から逆算して決める。 ここを最初に固めたので、実装が現実的なサイズに収まりました。


#☀️ 太陽:3 行で終わる

太陽の黄経は驚くほど短く書けます。

ts
function sunLongitude(T: number): number {
 const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T
 const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R
 const C =
 (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M) +
 (0.019993 - 0.000101 * T) * Math.sin(2 * M) +
 0.000289 * Math.sin(3 * M)
 return norm360(L0 + C)
}

T は J2000.0(2000年1月1日正午)からのユリウス世紀。L0 が平均黄経、M が平均近点角、C が中心差(軌道が楕円であることによるズレ)の補正です。

これで誤差 0.01 度。太陽は「星座がひとつずれる」ことがまず起きない精度で出ます。


#🌙 月:項を 6 つに絞る

月は厄介です。地球と太陽の両方から引っ張られるので、完全な式は数百項あります。Meeus の完全版でも 60 項以上。

主要 6 項だけに絞りました。

ts
function moonLongitude(T: number): number {
 const Lp = 218.3164477 + 481267.88123421 * T
 const D = (297.8501921 + 445267.1114034 * T) * D2R
 const M = (357.5291092 + 35999.0502909 * T) * D2R
 const Mp = (134.9633964 + 477198.8675055 * T) * D2R
 // Meeus 主要 6 項 (deg)
 const sigma =
 6.289 * Math.sin(Mp) +
 1.274 * Math.sin(2 * D - Mp) +
 0.658 * Math.sin(2 * D) +
 0.214 * Math.sin(2 * Mp) +
 -0.186 * Math.sin(M) +
 -0.114 * Math.sin(2 * D - M - Mp)
 return norm360(Lp + sigma)
}

係数を見ると分かるとおり、第 1 項(6.289度)が圧倒的に大きく、以降は急速に小さくなります。7 項目以降は 0.1 度未満なので、落としても 0.3 度程度の誤差で収まる。支配的な項だけ残すという判断がそのまま式に出ています。

月は移動が速い(1 日約 13 度)ので、時刻を入れないと数度ずれます。そのため出生時刻の入力欄を用意しつつ、未入力なら正午として扱っています。


#🪐 惑星:Kepler 方程式を Newton 法で解く

惑星は一段複雑です。惑星の位置を太陽中心で求め、地球の位置も求め、その差を取る必要があります(地球から見た方向が欲しいので)。

軌道要素には NASA JPL が公開している J2000.0 の近似値(Standish & Williams)を使いました。

ts
const ELEMENTS: Record<string, OrbitalElements> = {
 mercury: { a: 0.38709927, aRate: 0.00000037, e: 0.20563593, ... },
 venus: { a: 0.72333566, aRate: 0.00000390, e: 0.00677672, ... },
 earth: { a: 1.00000261, aRate: 0.00000562, e: 0.01671123, ... },
 ...
}

a が軌道長半径、e が離心率、L が平均黄経。Rate 付きが 1 世紀あたりの変化率です。

ここから実際の位置を出すには、Kepler 方程式 M = E - e·sin(E)E(離心近点角)について解く必要があります。この式は代数的に解けないので、数値解法を使います。

ts
function solveKepler(M: number, e: number): number {
 // Newton-Raphson, M in radians
 let E = M
 for (let i = 0; i < 8; i++) {
 const f = E - e * Math.sin(E) - M
 const fp = 1 - e * Math.cos(E)
 E = E - f / fp
 }
 return E
}

Newton-Raphson 法を 8 回。太陽系の惑星の離心率はどれも小さく(最大でも水星の 0.21)、初期値を M にすれば 3〜4 回で収束します。8 回は余裕を見た回数で、収束判定を書くよりループを固定したほうが分岐がなく速い、という判断です。


#📐 アスペクト判定はオーブとの勝負

天体の位置が出たら、あとは総当たりで角度を測ります。

ts
export const ASPECT_DEFS = [
 { type: "conjunction", angle: 0, orb: 8, jp: "合 (コンジャンクション)", nature: "neut" },
 { type: "sextile", angle: 60, orb: 4, jp: "六分 (セクスタイル)", nature: "harm" },
 { type: "square", angle: 90, orb: 6, jp: "四分 (スクエア)", nature: "tens" },
 { type: "trine", angle: 120, orb: 6, jp: "三分 (トライン)", nature: "harm" },
 { type: "opposition", angle: 180, orb: 8, jp: "衝 (オポジション)", nature: "tens" },
]

orb は「その角度とみなす許容範囲」です。ぴったり 90 度でなくても、84〜96 度ならスクエアとして扱う。この値は占星術の伝統的な慣行から取っています(メジャーアスペクトほど広く、マイナーほど狭い)。

ts
for (let i = 0; i < positions.length; i++) {
 for (let j = i + 1; j < positions.length; j++) {
 let diff = Math.abs(positions[i].longitude - positions[j].longitude)
 if (diff > 180) diff = 360 - diff
 for (const def of ASPECT_DEFS) {
 const orb = Math.abs(diff - def.angle)
 if (orb <= def.orb) {
 aspects.push({ ..., type: def.type, orb, exact: def.angle })
 break // 最短のみ
 }
 }
 }
}

7 天体なので組み合わせは 21 通り。総当たりで十分です。

diff > 180 のときに 360 - diff にしているのは、角度を「短いほう」で測るため。350 度離れているのは 10 度離れているのと同じです。

break を入れているのは、ひとつのペアに複数のアスペクトを割り当てないため。オーブの範囲は重ならないよう設計されているので実際には競合しませんが、定義を後から足したときに二重計上しないための保険です。


#😓 苦労したところ

#正規化を忘れると突然おかしくなる

天文計算は角度の足し算だらけで、放っておくと数千度や負の値になります。norm360 で 0〜360 に畳む処理を、すべての出口に入れる必要がある。

これを 1 か所忘れると、星座の割り当て(Math.floor(lon / 30))が範囲外のインデックスになって、そこだけ undefined が出る。しかも特定の生年月日でしか再現しないので、原因を掴むまでに時間がかかりました。

最終的に、位置を返す関数はすべて norm360 を通してから return する、という規約に統一しました。

#検算の相手をどうするか

自分の実装が合っているかを確かめる方法が最初なかったのが、地味に一番の障害でした。

やったのは、既知の値との突き合わせです。

  • 春分の日に太陽の黄経が 0 度付近になるか
  • 満月の日に太陽と月の差が 180 度付近になるか
  • 公開されている天体暦の値と、いくつかの日付で照合する

このうち春分と満月は、外部データなしで自己検証できるのが良いところでした。式の中に「春分」という概念は入っていないので、ここが合うということは計算が概ね正しいということです。

#生まれた時刻が分からない人が多い

これは実装というより設計の問題です。出生時刻を厳密に要求すると、母子手帳を探しに行かないと使えないサービスになる。

時刻は任意入力とし、未入力なら正午として扱う形にしました。そのうえで、時刻によって特に動くのは月であることを画面に明記しています。入力の敷居を下げつつ、精度の前提は隠さない。 占いを扱うサービスでは、この線引きを曖昧にしないことが信用に効くと思っています。


#🔭 今後の拡張

  • 天王星・海王星・冥王星 — 世代天体を足す(動きが遅いので同世代で同じ配置になる点の説明も要る)
  • ハウス分割 — 出生地の緯度経度が必要になるので、入力設計から考え直しになる
  • 相性図(シナストリー) — 2 人の天体を重ねてアスペクトを取る
  • トランジット — 現在の天体位置と出生図を比較して「今の運勢」を出す

ハウスまで入ると本格的な出生図になりますが、緯度経度の入力が増える分だけ離脱も増える。段階を分けて、まずアスペクトだけで完結する今の形を磨くのが先だと考えています。


#💡 このサービスから言えること

必要な精度を用途から決めると、実装可能になる。 天体位置を「正確に」出そうとすると数百 MB の天文暦が要りますが、「アスペクト判定に使う」なら誤差 0.3 度で足ります。オーブが 4〜8 度あるからです。

要求を無条件に高く設定すると、そもそも作れません。どこまで雑にしていいかを先に決めるのが、ブラウザ完結で何かを作るときの実質的な設計作業でした。

もうひとつ。古典的なアルゴリズム——Meeus の式も Newton-Raphson 法も何十年も前からあるもの——が、今もそのまま使えることの安心感があります。新しいライブラリを探す前に、その分野で枯れている手法を調べるほうが、依存も増えず結果も安定する。占星術という古いテーマを扱うサービスで、実装まで古典に助けられたのは面白い経験でした。

[ ./next_action ]

読んだら、 AspectChart を実際に動かす。

この開発ログは AspectChart をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →