赤道座標2000年分点を現在分点へ変換

 スマート望遠鏡 Seestar で任意の天体 (RA, Dec) を登録するさい、座標が2000年点ではなく現在分点 (JNow) になっています。この仕様は(2025年時点において)、仮に2000年分点のまま天体登録をすると、天体の位置によっては、RA で数分角、Decで数分~数十分角のズレが導入時に生じます。新星などの新天体を観測するさい、座標の変換をしていたほうが(視野中央に導入されるため)、新天体の同定が楽チンなので、Seestarユーザーが少しでもハッピーになればと思い、Webアプリを作ってみた次第 (HTML + CSS + JavaScript)。Seestar で天体導入するには十分な計算結果が得られていると思われる。
 (2025.09.18 記)

J2000.0 → 現在分点変換

入力(J2000):
日時(UT):

RA (現在分点):
Dec (現在分点):

※日時はブラウザより自動取得しています。
※計算コードの原型は ChatGPT-5 で作成しています。
※あくまで自己責任でご利用ください。

コードは以下のとおり:

<div id="precessor-widget" style="max-width:640px;padding:1rem;border:1px solid #ccc;border-radius:8px;font-family:system-ui,Segoe UI,Arial;">
  <h3 style="margin-top:0">J2000.0 → 現在分点変換</h3>

  <div style="display:flex;gap:.5rem;flex-wrap:wrap">
    <label style="flex:1 1 280px">
      RA (J2000):<br>
      <input id="inRA" type="text" value="" placeholder="hh:mm:ss.s または hh mm ss.s" style="width:100%;padding:.5rem">
    </label>
    <label style="flex:1 1 280px">
      Dec (J2000):<br>
      <input id="inDec" type="text" value="" placeholder="±dd:mm:ss.s または ±dd mm ss.s" style="width:100%;padding:.5rem">
    </label>
  </div>

  <button id="doConvert" style="margin-top:1rem;padding:.6rem 1rem;border:0;border-radius:6px;background:#1a73e8;color:#fff;cursor:pointer">
    変換(J2000 → 現在分点)
  </button>

  <div id="out" style="margin-top:1rem;background:#f8f9fa;border:1px solid #e5e7eb;border-radius:6px;padding:.75rem">
    <div><strong>入力(J2000):</strong> <span id="echoIn">—</span></div>
    <div><strong>日時(UT):</strong> <span id="echoDate">—</span></div>
    <hr style="border:none;border-top:1px solid #e5e7eb;margin:.6rem 0">
    <div><strong>RA (現在分点):</strong> <span id="outRA" style="color:red">—</span></div>
    <div><strong>Dec (現在分点):</strong> <span id="outDec" style="color:red">—</span></div>
  </div>
</div>

<script>
// ========= 入力パーサ(: または 半角スペース区切りを許容) =========
function splitTokens(s){ return String(s).trim().replace(/\s+/g,' ').split(/[:\s]+/); }
function hmsToHoursFlexible(s){
  const tok = splitTokens(s);
  if(tok.length < 2) throw new Error('RAは hh:mm:ss もしくは hh mm ss 形式で入力してください');
  const hh = parseFloat(tok[0]), mm = parseFloat(tok[1] ?? '0'), ss = parseFloat(tok[2] ?? '0');
  if([hh,mm,ss].some(Number.isNaN)) throw new Error('RAの数値が不正です');
  return Math.sign(hh)*Math.abs(hh) + mm/60 + ss/3600;
}
function dmsToDegFlexible(s){
  const str = String(s).trim();
  const sign = str.startsWith('-') ? -1 : 1;
  const core = str.replace(/^[-+]/,'');
  const tok = splitTokens(core);
  if(tok.length < 2) throw new Error('Decは dd:mm:ss もしくは dd mm ss 形式で入力してください');
  const dd = Math.abs(parseFloat(tok[0])), mm = parseFloat(tok[1] ?? '0'), ss = parseFloat(tok[2] ?? '0');
  if([dd,mm,ss].some(Number.isNaN)) throw new Error('Decの数値が不正です');
  return sign*(dd + mm/60 + ss/3600);
}

// ========= 丸め&表示(秒は整数。繰り上がり対応) =========
function formatRA_hms_intSeconds(raHours){
  let totalSec = Math.round((((raHours%24)+24)%24) * 3600);
  if (totalSec === 24*3600) totalSec = 0;
  const hh = Math.floor(totalSec/3600); totalSec -= hh*3600;
  const mm = Math.floor(totalSec/60);   const ss = totalSec - mm*60;
  return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
}
function formatDec_dms_intSeconds(decDeg){
  const sign = decDeg < 0 ? '-' : '+';
  let totalSec = Math.round(Math.abs(decDeg)*3600);
  if (totalSec > 90*3600) totalSec = 90*3600; // 安全策
  const dd = Math.floor(totalSec/3600); totalSec -= dd*3600;
  const mm = Math.floor(totalSec/60);   const ss = totalSec - mm*60;
  return `${sign}${String(dd).padStart(2,'0')}:${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
}

// ========= 角度・ユーティリティ =========
const DEG2RAD = Math.PI/180;
const RAD2DEG = 180/Math.PI;
function toRadians(deg){ return deg*DEG2RAD; }
function toDegrees(rad){ return rad*RAD2DEG; }

// ========= 時刻:JD(UTC) → JD(TT) 近似 =========
function jdTTfromUTC(dateObj){
  // 2025年の近似:TT-UTC ≈ 69.184 s(TAI-UTC=37s; TT=TAI+32.184)
  const JD_UNIX_EPOCH = 2440587.5;
  const jdUTC = JD_UNIX_EPOCH + dateObj.getTime()/86400000;
  return jdUTC + 69.184/86400;
}

// ========= 歳差(IAU 1976/2000 近似) =========
function precessionAngles(t){
  const zeta = ((2306.2181+1.39656*t-0.000139*t*t)*t+(0.30188-0.000344*t)*t*t+0.017998*t*t*t);
  const z    = ((2306.2181+1.39656*t-0.000139*t*t)*t+(1.09468+0.000066*t)*t*t+0.018203*t*t*t);
  const theta= ((2004.3109-0.85330*t-0.000217*t*t)*t-(0.42665+0.000217*t)*t*t-0.041833*t*t*t);
  const as2r = (Math.PI/180)/3600;
  return {zeta:zeta*as2r, z:z*as2r, theta:theta*as2r};
}
function R1(a){ const s=Math.sin(a), c=Math.cos(a); return [[1,0,0],[0,c,s],[0,-s,c]]; }
function R2(a){ const s=Math.sin(a), c=Math.cos(a); return [[c,0,-s],[0,1,0],[s,0,c]]; }
function R3(a){ const s=Math.sin(a), c=Math.cos(a); return [[c,s,0],[-s,c,0],[0,0,1]]; }
function matMul(A,B){ const C=[[0,0,0],[0,0,0],[0,0,0]]; for(let i=0;i<3;i++)for(let j=0;j<3;j++)for(let k=0;k<3;k++)C[i][j]+=A[i][k]*B[k][j]; return C; }
function matVec(A,v){ return [A[0][0]*v[0]+A[0][1]*v[1]+A[0][2]*v[2], A[1][0]*v[0]+A[1][1]*v[1]+A[1][2]*v[2], A[2][0]*v[0]+A[2][1]*v[1]+A[2][2]*v[2]]; }

// ========= 平均黄道傾斜(ε̄) =========
function meanObliquity(t){
  // arcseconds
  const eps = 84381.448 - 46.8150*t - 0.00059*t*t + 0.001813*t*t*t;
  return eps * (Math.PI/180) / 3600; // radians
}

// ========= 章動(簡略版:Meeusの近似;主成分) =========
// Δψ, Δε をラジアンで返す(小さくても回転なのでラジアンでOK)
function nutationMeeusApprox(jdTT){
  const T = (jdTT - 2451545.0)/36525.0; // Julian centuries (TT)

  // 基本引数(度) - Meeus (1998) Chapter 22 式
  const L  = 280.4665 + 36000.7698*T;                               // 太陽の平均黄経(≒黄経)[deg]
  const Ls = 357.5291 + 35999.0503*T;                               // 太陽の平均近点離角(M_sun)[deg]
  const Lm = 134.96298 + 477198.867398*T + 0.0086972*T*T;           // 月の平均近点離角(M_moon)[deg] 近似
  const D  = 297.85036 + 445267.111480*T - 0.0019142*T*T;           // 月の平均伸開角 [deg]
  const Om = 125.04452 - 1934.136261*T + 0.0020708*T*T;             // 月昇交点黄経 [deg]

  // 角度をラジアンへ
  const Lr  = (L%360)*DEG2RAD;
  const Lsr = (Ls%360)*DEG2RAD;
  const Lmr = (Lm%360)*DEG2RAD;
  const Dr  = (D%360)*DEG2RAD;
  const Omr = (Om%360)*DEG2RAD;

  // 簡略式(単位:arcsec)
  // Δψ ≈ -17.20*sinΩ - 1.32*sin(2L_sun) - 0.23*sin(2L_moon) + 0.21*sin(2Ω)
  // Δε ≈  +9.20*cosΩ + 0.57*cos(2L_sun) + 0.10*cos(2L_moon) - 0.09*cos(2Ω)
  const dpsi_as = -17.20*Math.sin(Omr)
                  - 1.32*Math.sin(2*Lsr)
                  - 0.23*Math.sin(2*Lmr)
                  + 0.21*Math.sin(2*Omr);

  const deps_as =  +9.20*Math.cos(Omr)
                  + 0.57*Math.cos(2*Lsr)
                  + 0.10*Math.cos(2*Lmr)
                  - 0.09*Math.cos(2*Omr);

  const as2r = (Math.PI/180)/3600;
  return { dpsi: dpsi_as*as2r, deps: deps_as*as2r };
}

// ========= メイン:J2000 → 真分点(歳差+章動) =========
function j2000ToTrueOfDate(raHours, decDeg, jdTT){
  const t = (jdTT - 2451545.0)/36525.0;

  // 歳差:J2000 → 平均赤道・平均分点(of date)
  const {zeta,z,theta} = precessionAngles(t);
  const P = matMul( matMul(R3(-z), R2(theta)), R3(-zeta) );

  // 章動:平均 → 真(of date)
  const eps_bar = meanObliquity(t);               // 平均黄道傾斜 ε̄
  const {dpsi,deps} = nutationMeeusApprox(jdTT);  // Δψ, Δε
  const N = matMul( R1(-(eps_bar+deps)), matMul( R3(-dpsi), R1(eps_bar) ) );

  // 合成回転:J2000 → 真分点(of date)
  const X = matMul(N, P);

  // ベクトル化→回転→(α,δ)
  const a = toRadians(raHours*15), d = toRadians(decDeg);
  const r0 = [Math.cos(d)*Math.cos(a), Math.cos(d)*Math.sin(a), Math.sin(d)];
  const r  = matVec(X, r0);

  const ra  = Math.atan2(r[1], r[0]);
  const dec = Math.asin(r[2]);
  let raH = toDegrees(ra)/15; if(raH<0) raH += 24;
  return { raHours: raH, decDeg: toDegrees(dec) };
}

// ========= UI結線 =========
(function(){
  const $ = id => document.getElementById(id);
  $('doConvert').addEventListener('click', ()=>{
    try{
      const raH  = hmsToHoursFlexible($('inRA').value);
      const decD = dmsToDegFlexible($('inDec').value);
      const now  = new Date();                  // ブラウザ現在(UTC)
      const jdTT = jdTTfromUTC(now);            // TT 近似
      const res  = j2000ToTrueOfDate(raH, decD, jdTT);

      $('echoIn').textContent   = `RA ${$('inRA').value}, Dec ${$('inDec').value}`;
      $('echoDate').textContent = `${now.toISOString().replace('T',' ').replace('Z','')} (JD_TT ≈ ${jdTT.toFixed(5)})`;
      $('outRA').textContent    = formatRA_hms_intSeconds(res.raHours);
      $('outDec').textContent   = formatDec_dms_intSeconds(res.decDeg);
    }catch(e){
      alert(e.message||e);
    }
  });
})();
</script>