「今日の月、何日目だっけ」を確かめるとき、月齢カレンダーをググるのが面倒だったので 200 行で書いた。Julian Date を計算して、参照新月からの経過日数を朔望月 29.530588853 日で割って小数部を取るだけで月相が出る。SVG で月面を描くときの terminator (明暗境界) を ellipse arc で表現する方法がちょっと面白かったのでまとめる。
🌐 デモ: https://sen.ltd/portfolio/moon-phase/
📦 GitHub: https://github.com/sen-ltd/moon-phase
全体像
純粋ロジック phase.js は約 150 行 + テスト 30 個。Date を受け取って { phase, age, illumination, name, waxing } を返す。これだけ:
import { moonPhase } from "./phase.js";
const info = moonPhase(new Date());
// → { phase: 0.1141, age: 3.4, illumination: 0.123, name: "三日月", waxing: true }
UI 側 (script.js) は SVG の path を更新するだけ。ロジックは DOM に一切触らない ので Node の --test でそのままテストできる。
Julian Date — 天文学者の通し日
天体計算は普通の Gregorian Calendar だとうるう年や月の長さの違いが邪魔になるので、通し日数で表す Julian Date (JD) を使う。基準は紀元前 4713 年 1 月 1 日 正午 UT で、1 日 = 1.0 (時刻は小数部) という定義。
Unix epoch を JD に変換する定数は決まっている:
export const UNIX_EPOCH_JD = 2440587.5; // 1970-01-01 00:00 UTC の JD
export const MS_PER_DAY = 86400000;
export function dateToJulian(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
return date.getTime() / MS_PER_DAY + UNIX_EPOCH_JD;
}
なぜ .5 がつくかというと、JD は 正午起算 (午前から 1 日が始まると面倒なので天文学者は昼始まりに決めた)。0 時 UTC は 0.5 日進んだ位置 = .5。テストで定義どおりであることをピン留めしておく:
test("dateToJulian matches the unix epoch as JD 2440587.5", () => {
assert.equal(dateToJulian(new Date("1970-01-01T00:00:00Z")), 2440587.5);
});
位相は「参照新月からの経過日数を朔望月で割った小数部」
朔望月 (synodic month) は新月から次の新月までの期間。平均すると 29.530588853 日。
参照新月として JD 2451550.1 (2000-01-06 14:24 UTC の新月) を使うのが慣例。NASA のエフェメリスでこの瞬間が新月であることが確認されている。
export const SYNODIC_MONTH = 29.530588853;
export const REFERENCE_NEW_MOON_JD = 2451550.1;
export function moonPhase(date) {
const jd = dateToJulian(date);
const daysSinceRef = jd - REFERENCE_NEW_MOON_JD;
let phase = (daysSinceRef / SYNODIC_MONTH) % 1;
if (phase < 0) phase += 1;
// ...
}
phase ∈ [0, 1) で、0 が新月、0.25 が上弦の月、0.5 が満月、0.75 が下弦の月。
% 演算子のマイナス問題
JavaScript の % は数学の剰余ではなく truncated modulo で、被除数が負だと結果も負になる:
-3 % 10 // → -3 (Python なら 7)
参照新月より前の日付を与えると daysSinceRef が負になり、phase も負になってしまう。なので if (phase < 0) phase += 1 で正規化する。テスト:
test("moonPhase at the reference new moon JD gives phase ≈ 0", () => {
const d = julianToDate(REFERENCE_NEW_MOON_JD);
const info = moonPhase(d);
assert.ok(info.phase < 0.001 || info.phase > 0.999);
});
test("moonPhase one synodic month after the reference is again ~new", () => {
const d = julianToDate(REFERENCE_NEW_MOON_JD + SYNODIC_MONTH);
const info = moonPhase(d);
assert.ok(info.phase < 0.001 || info.phase > 0.999);
});
照度 = (1 − cos(2π × phase)) / 2
月の太陽光側 (=見かけ上の lit fraction) は 位相の cos の線形変換 で表せる:
const illumination = (1 - Math.cos(2 * Math.PI * phase)) / 2;
これは「太陽-月-地球 のなす角度のコサインに基づく投影面積」を [0, 1] に正規化したもの。
| phase | cos(2π × phase) | illumination |
|---|---|---|
| 0 (新月) | 1 | 0 |
| 0.25 (上弦) | 0 | 0.5 |
| 0.5 (満月) | -1 | 1 |
| 0.75 (下弦) | 0 | 0.5 |
上弦と下弦は 照度では区別できない。waxing (満ちる) か waning (欠ける) かは phase < 0.5 で判定する。
SVG の terminator は ellipse arc 2 本で描ける
ここが一番面白かったところ。月の terminator (明暗境界線) は球面を真横から見たときの大円の射影で、平面投影すると 楕円弧になる。
月面全体は半径 r の円。lit (明るい) 領域は次の組み合わせで表せる:
- 外側の半円弧 — 円の右半分または左半分 (満ち欠けの向きで決まる)
-
内側の半楕円弧 — 半長軸
r、半短軸r × |cos(2π × phase)|
export function terminatorPath(phase, radius) {
if (phase < 0.001 || phase > 0.999) return ""; // 新月: 何も描かない
if (Math.abs(phase - 0.5) < 0.001) return circlePath(radius); // 満月: 円全部
const cosPhase = Math.cos(2 * Math.PI * phase);
const minor = Math.abs(cosPhase) * radius;
const r = radius;
const waxing = phase < 0.5;
const outerSweep = waxing ? 1 : 0; // 満ちる: 右、欠ける: 左
const innerSweep = cosPhase < 0 ? 1 : 0; // gibbous (照度 > 0.5): 反対側に膨らむ
return [
`M 0 ${-r}`,
`A ${r} ${r} 0 0 ${outerSweep} 0 ${r}`, // 外側半円
`A ${minor} ${r} 0 0 ${innerSweep} 0 ${-r}`, // 内側半楕円
"Z",
].join(" ");
}
SVG arc コマンドのパラメータ
A rx ry x-axis-rotation large-arc-flag sweep-flag x y の 7 引数。今回ハマったのは sweep-flag:
-
0: 開始点から終了点へ 反時計回り -
1: 開始点から終了点へ 時計回り
M 0 -r (上端) から A ... 0 r (下端) に向かう半円弧で、
- waxing (右が明るい) なら時計回り = sweep 1
- waning (左が明るい) なら反時計回り = sweep 0
gibbous の bulge 方向
gibbous (illumination > 0.5) のとき terminator は dark 側 (暗い側) に膨らむ。crescent (illumination < 0.5) のとき lit 側に膨らむ (というか、楕円が lit と重なって crescent 形を作る)。
cos(2π × phase) の符号がこれを完璧に表現する:
-
phase ∈ (0, 0.25)(waxing crescent): cosPhase > 0 → ellipse は lit 側を侵食 -
phase ∈ (0.25, 0.5)(waxing gibbous): cosPhase < 0 → ellipse は dark 側へ膨らむ -
phase ∈ (0.5, 0.75)(waning gibbous): cosPhase < 0 → 同上 -
phase ∈ (0.75, 1)(waning crescent): cosPhase > 0 → 同上
innerSweep = cosPhase < 0 ? 1 : 0 の 1 行でこれを全部処理できる。テストで両方の挙動を pin:
test("terminatorPath gibbous bulges into dark side (sweep flag 1 on inner arc)", () => {
const path = terminatorPath(0.4, 100); // 上弦と満月の中間 (waxing gibbous)
const arcs = path.match(/A [^A]+/g);
assert.equal(arcs.length, 2);
assert.ok(arcs[1].includes("0 0 1 0")); // 内側 arc の sweep flag は 1
});
次の新月・満月を二分探索なしで求める
「次の満月いつ?」を求める素朴な方法は、明日から 1 日ずつ進めて Math.abs(phase - 0.5) < ε をチェック…だが、もっとシンプルに代数的に解ける。
phase は JD の線形関数なので逆算可能:
phase(jd) = ((jd - REF) / S) mod 1
(phase の値が target になる jd) = REF + (target + k) × S (k は整数)
開始時点より後の最小の k を見つけるだけ:
export function nextPhase(date, target) {
const startJd = dateToJulian(date);
const offset = (startJd - REFERENCE_NEW_MOON_JD) / SYNODIC_MONTH;
const k = Math.ceil(offset - target);
const jd = REFERENCE_NEW_MOON_JD + (target + k) * SYNODIC_MONTH;
return julianToDate(jd);
}
offset は「開始時点が参照新月から何周期離れているか」(小数含む)。Math.ceil(offset - target) で「target を超える最小の整数 k」を出している。
連続する 2 つの新月の間隔がきちんと SYNODIC_MONTH になることをテスト:
test("consecutive new moons are ~SYNODIC_MONTH days apart", () => {
const first = nextNewMoon(new Date("2024-01-01T00:00:00Z"));
const second = nextNewMoon(new Date(first.getTime() + 86400000)); // 翌日以降を探索
const diff = (second - first) / 86400000;
assert.ok(Math.abs(diff - SYNODIC_MONTH) < 0.01);
});
月相名のビン境界
「三日月」「上弦の月」「十三夜月」… の 8 つの名前を phase ∈ [0, 1) の 8 ビンに割り当てる。ビンの境界が canonical phase の中央に来る ように 1/16 をオフセットする:
const idx = Math.floor((p + 1 / 16) * 8) % 8;
phase = 0 (新月) は (0 + 1/16) × 8 = 0.5 → floor → 0、phase = 0.25 (上弦の月) は (0.25 + 1/16) × 8 = 2.5 → 2 …と canonical な値が綺麗にビン中央に来る。境界 (1/16, 3/16, 5/16, …) で名前が切り替わる。
test("phaseName maps canonical phases to expected labels", () => {
assert.equal(phaseName(0), "新月");
assert.equal(phaseName(0.25), "上弦の月");
assert.equal(phaseName(0.5), "満月");
assert.equal(phaseName(0.75), "下弦の月");
});
test("phaseName handles wrap-around", () => {
assert.equal(phaseName(1.0), "新月");
assert.equal(phaseName(-0.25), "下弦の月");
});
精度の話 — 平均朔望月による近似
SYNODIC_MONTH = 29.530588853 は 平均値。実際の朔望月は地球軌道の楕円性 (近日点で速い) や月軌道の摂動で ±約 7 時間変動する。
このコードの精度:
- カジュアル用途 (「今日の月相を知りたい」) には十分
- 新月/満月の正確な時刻には ±12 時間程度の誤差
- 厳密な天体計算には ELP-2000/82 のような数百項の摂動展開が必要
このトレードオフは README に明記した。150 行で書ける範囲では妥当な精度。
まとめ
-
Julian Date で天体計算を Date 演算から切り離す。Unix → JD は
ms / 86400000 + 2440587.5。 -
位相 =
(JD - 参照新月JD) / 29.530588853 mod 1。%がマイナスを返すケースに気をつける。 -
照度 =
(1 - cos(2π × phase)) / 2。上弦と下弦は照度では区別できないのでwaxingフラグも持つ。 - SVG の terminator は外側半円 + 内側半楕円の 2 本の arc で描ける。sweep-flag は waxing/waning と gibbous/crescent で 2 軸独立に決まる。
- 次の満月/新月 は二分探索不要。phase が JD の線形関数なので代数で逆算できる。
- 平均朔望月による近似なので精度は ±12 時間程度。厳密用途には不適。
ソース: https://github.com/sen-ltd/moon-phase — MIT、約 150 行 + テスト 30 個、依存ゼロ、ビルドなし。
🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.
