以下の X 投稿にインスピレーションを受けて、React + Three.js で「Astro UI」アプリケーションをつくってみたので、その処理ロジックについて、数学的背景と実装の詳細を解説していこうと思います。
今回つくったアプリケーションは、任意の都市と日付を選択することで、その日の太陽と月の動きを 3D で可視化するものです。
ソースコードは github で公開しています。
ここでは、なぜその実装方法を選んだのか、どのような数学的変換が行われているのか、どのような技術的課題を解決したのかに焦点を当てて解説します。
モジュール構成
アプリケーションは以下の主要なモジュールで構成されています。
-
天文計算モジュール (
lib/astro.ts):太陽・月の位置計算とタイムゾーン処理 -
地理情報モジュール (
lib/geo.ts): 都市検索 API との連携 -
3D オブジェクト構築モジュール (
three/buildObjects.ts):座標変換と Three.js オブジェクトの生成 -
3D シーン管理 (
three/Scene3D.tsx):シーンの描画とインタラクション処理 -
UI コンポーネント (
ui/Sidebar.tsx,App.tsx):ユーザーインターフェース
1. 座標系の変換
1.1 問題の本質
太陽や月の位置は、天文学では地平座標系で表現されます。これは観測者を中心とした座標系で、以下の 2 つの角度で位置を指定します。
- 方位角(Azimuth):北を 0° として時計回りに測った角度(0°~360°)
- 高度角(Altitude):地平線からの角度(-90°~+90°、地平線より上は正)
しかし、Three.js で 3D 表示するには、3D 直交座標系(x, y, z)への変換が必要です。この変換は、単純な三角関数の組み合わせで実現できます。
1.2 実装
観測者を原点とし、半径 R の球面上に太陽や月の位置を配置します。座標系は x=東、y=上、z=北です。buildObjects.ts の toXYZ で地平座標(方位角・高度角)を 3D 座標に変換しています。
export function toXYZ(p: TrajPoint, R: number) {
const az = p.azN;
const alt = p.alt;
const r = R * Math.cos(alt);
const x = r * Math.sin(az);
const z = r * Math.cos(az);
const y = R * Math.sin(alt);
return new THREE.Vector3(x, y, z);
}
1.3 視覚的な理解
以下の図は、座標変換の概念を視覚化したもので、球面上の点は、水平面(XZ 平面)への投影と Y 方向の高さの組み合わせで求めています。

座標変換
1.4 方位角の北基準への変換
SunCalc は方位角を南を 0 として時計回りで返すため、北基準に変換しています。
function toAzFromNorth(suncalcAzSouth0WestPlus: number): number {
const twoPi = Math.PI * 2;
const azN = (suncalcAzSouth0WestPlus + Math.PI) % twoPi;
return azN < 0 ? azN + twoPi : azN;
}
2. タイムゾーン処理
2.1 課題
地球は 24 のタイムゾーンに分かれており、同じ UTC 時刻でも現地時間は異なります。さらに、夏時間の影響も考慮する必要があります。
なぜタイムゾーンが重要か
太陽や月の位置は、観測者の現地時間に基づいて計算する必要があります。例えば、東京(UTC+9)の正午とロンドン(UTC+0)の正午では、太陽の位置が大きく異なります。UTC 時刻で計算すると、現地時間の正午に太陽が天頂に来るという期待に反する結果になります。
2.2 実装アプローチ
このアプリケーションでは、以下の手順で現地時間を処理します。
- **タイムゾーンの自動検出:**緯度・経度から IANA タイムゾーン名(例:"Asia/Tokyo")を取得
-
現地時間での計算:Luxon の
DateTimeを使用して、現地時間の 1 日(00:00~23:59)を生成 -
UTC への変換:
toJSDate()により、JavaScript のDateオブジェクト(UTC タイムスタンプを保持)に変換 - SunCalc での計算:SunCalc は UTC 時刻として解釈するため、現地時間から UTC への変換が自動的に行われる
2.3 時刻変換の流れ
以下の図は、時刻変換の流れを示しています。
現地時間(東京) UTC 時刻 SunCalc 計算
─────────────────────────────────────────────────────────
2025-01-28 00:00 JST → 2025-01-27 15:00 UTC → 位置計算
2025-01-28 12:00 JST → 2025-01-28 03:00 UTC → 位置計算
2025-01-28 23:59 JST → 2025-01-28 14:59 UTC → 位置計算
この変換により、現地時間の正午に太陽が最も高い位置に来るという、直感的な結果が得られます。
2.4 ツールチップ表示での時刻変換
マウスオーバー時に表示する時刻も、同様に現地時間に変換する必要があります。
Dateオブジェクトは UTC タイムスタンプを保持しているため、Luxon で UTC → 指定タイムゾーンに変換します。
function formatTimeLocal(d: Date, tz: string) {
const dt = DateTime.fromJSDate(d, { zone: "utc" });
const dtLocal = dt.setZone(tz);
return `${pad2(dtLocal.hour)}:${pad2(dtLocal.minute)}`;
}
3. 3Dオブジェクトの構築
3.1 軌跡の線分分割
地平線より下の部分を非表示にする場合、連続した線分として描画すると、地平線をまたぐ部分が不自然に見えるため、地平線で線を分割し、地平線より上の部分のみを描画します。
3.2 点の生成とインデックスマッピング
各軌跡点をクリック可能な点として描画する際、showBelowHorizonがfalseの場合、地平線より下の点がスキップされるため、表示される点のインデックスと元の軌跡配列のインデックスが一致しなくなるという課題があります。
これを解決するため、indexMap配列を使用して、表示される点のインデックスから元の軌跡配列のインデックスへのマッピングを保持します。地平線より下をスキップするたびに「表示インデックス ≠ 元のインデックス」になるため、indexMap[displayIdx] で元のインデックスを取得します。
// buildPoints 内:表示する点だけ verts に積み、元のインデックスを indexMap に保存
const indexMap: number[] = [];
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!showBelowHorizon && p.alt < 0) continue;
const v = toXYZ(p, R);
verts.push(v.x, v.y, v.z);
indexMap.push(i);
// ...
}
pointsObj.userData.indexMap = indexMap
// onMouseMove 内:ヒットした点の表示インデックスから元の軌跡点を取得
const displayIdx = hit.index;
const indexMap = obj.userData?.indexMap;
const idx = indexMap?.[displayIdx] ?? displayIdx;
const p = traj[idx];
3.3 シェーダーによる美しい点の描画
Three.js のデフォルトのPointsMaterialでは、点のサイズが固定されており、カメラからの距離に応じて小さく見えてしまいます。
これを解消するため、カスタムシェーダーマテリアルで、カメラ距離に応じた点サイズと、smoothstep によるソフトな円形を描画しています。
// vertexShader
attribute float size;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (300.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
// fragmentShader
uniform vec3 uColor;
void main() {
vec2 c = gl_PointCoord - vec2(0.5);
float d = dot(c, c);
float a = smoothstep(0.25, 0.0, d);
gl_FragColor = vec4(uColor, a);
}
3.4 月の照度に応じた点のサイズ調整
月の軌跡では、照度(0=新月、1=満月)に応じて点のサイズを変えています。
const k = p.illum ?? 1;
sizes.push(size * (0.6 + 0.8 * k));
4. インタラクション
4.1 レイキャスティングの原理
マウスオーバー時に軌跡上の点を検出するため、Three.js のRaycasterを使用します。レイキャスティングは、カメラからマウス位置に向かって仮想的な光線(レイ)を発射し、その光線と交差するオブジェクトを検出する技術です。

レイキャスティング
4.2 座標変換の流れ
マウス位置を画面座標から正規化デバイス座標(NDC)に変換し、Raycaster.setFromCameraでレイを生成しています。Y は画面座標系と NDC で上下が逆なため反転しています。
const rect = renderer.domElement.getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
const y = -(((ev.clientY - rect.top) / rect.height) * 2 - 1);
mouseNdc.set(x, y);
raycaster.setFromCamera(mouseNdc, camera);
4.3 点の検出範囲
点の検出範囲は、raycaster.params.Points.thresholdで指定します。レイからこの距離以内の点をヒット対象にします。
raycaster.params.Points = { threshold: 0.06 };
5. 月相の描画
5.1 問題の本質
月相(満ち欠け)を描画するには、球面に光が当たったときの陰影を計算する必要があります。これは、各ピクセルについて、球面の法線ベクトルと光の方向の内積を計算することで実現できます。

5.2 光の方向
月齢(0=新月、1=満月)から光の方向ベクトルの XZ 成分を計算しています。観測者は +Z 向きを想定。
const phase = ill.phase;
const alpha = 2 * Math.PI * phase;
const lx = Math.sin(alpha);
const lz = -Math.cos(alpha);
5.3 球面の法線ベクトル
各ピクセルについて、球の中心からの相対座標で球面上かどうかを判定し、球内なら単位法線(nx, ny, nz)を計算しています。
const dx = x + 0.5 - cx;
const dy = y + 0.5 - cy;
const rr = dx * dx + dy * dy;
if (rr > r * r) { img.data[idx + 3] = 0; continue; }
const nx = dx / r;
const ny = dy / r;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
5.4 輝度の計算
法線と光の方向の内積で明るさの元を求め、edgeSoftness で境界を滑らかにし、edgeBoost で少し強調しています。
let ndotl = nx * lx + nz * lz;
const edgeSoftness = 0.035;
const edgeBoost = 0.85;
const t0 = -edgeSoftness;
const t1 = +edgeSoftness;
let lit = (ndotl - t0) / (t1 - t0);
lit = Math.min(1, Math.max(0, lit));
lit = Math.pow(lit, edgeBoost);
5.5 リム暗化
球の端を少し暗くするリム暗化と、環境光・ガンマ補正をかけて最終輝度を求めています。実装は次のとおりです。
const ambient = 0.02;
const gamma = 0.95;
const limbDark = 0.10;
const rim = 1 - nz;
const rimFactor = 1 - limbDark * rim;
let I = ambient + (1 - ambient) * lit;
I = Math.pow(Math.min(1, Math.max(0, I * rimFactor)), gamma);
5.6 RGB 値の計算
輝度で明るい色と暗い色を線形補間して各ピクセルの RGB を決めています。
img.data[idx + 0] = darkRGB.r + (litRGB.r - darkRGB.r) * I;
img.data[idx + 1] = darkRGB.g + (litRGB.g - darkRGB.g) * I;
img.data[idx + 2] = darkRGB.b + (litRGB.b - darkRGB.b) * I;
img.data[idx + 3] = 255;
6. パフォーマンス最適化
6.1 メモ化による再計算の回避
軌跡データの計算は、都市の選択や日付が変更されたときのみ実行されます。React の useMemo フックを使用して、依存配列(slots, dateISO, stepMin)が変更されたときのみ再計算します。
なぜメモ化が重要か
軌跡データの計算は、1 日分(10 分間隔の場合 144 点)のデータを生成するため、計算コストがかかります。メモ化により、不要な再計算を避けることで、UI の応答性が向上します。
6.2 3D シーンの再構築
Scene3Dコンポーネントは、useEffectの依存配列に基づいて、必要なときのみシーンを再構築します。クリーンアップ関数では、以下のリソースを適切に解放します。
-
cancelAnimationFrame(raf):アニメーションループの停止 -
controls.dispose():OrbitControls のリソース解放 -
renderer.dispose():WebGL コンテキストのリソース解放 - DOM 要素の削除:ツールチップやバッジなどの DOM 要素の削除
なぜクリーンアップが重要か
Three.js のリソース(WebGL コンテキスト、バッファ、テクスチャなど)は、明示的に解放しないとメモリリークの原因になります。特に、コンポーネントが頻繁に再マウントされる場合、リソースの適切な解放が重要です。






