2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

月相をブラウザで計算する — Julian Date、朔望月、SVG の terminator を ellipse arc で描く

2
Posted at

「今日の月、何日目だっけ」を確かめるとき、月齢カレンダーをググるのが面倒だったので 200 行で書いた。Julian Date を計算して、参照新月からの経過日数を朔望月 29.530588853 日で割って小数部を取るだけで月相が出る。SVG で月面を描くときの terminator (明暗境界) を ellipse arc で表現する方法がちょっと面白かったのでまとめる。

moon-phase の UI: 大きな三日月の SVG、位相名 "三日月"、月齢 3.4 日、照度 12.3%、位相 0.1141、満ち欠け方向 "満ちる (waxing)"、次の新月 6月15日、次の満月 5月31日。下に 2026年5月の月相カレンダー (各日の月相がミニ SVG で並ぶ)。

🌐 デモ: 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 (明るい) 領域は次の組み合わせで表せる:

  1. 外側の半円弧 — 円の右半分または左半分 (満ち欠けの向きで決まる)
  2. 内側の半楕円弧 — 半長軸 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 → 0phase = 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.

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?