- pendingCam now restores heading+pitch (inverse of serializeHash spherical), not just distance — pasted URLs reproduce the exact view (Stage 2 gate) - bodyWorld.sun allocated once, not per frame (§11 zero-alloc tick rule) - spacecraft/comet label DOM writes throttled ≤5 Hz (§11); geometry still per-frame Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
184 lines
8.7 KiB
JavaScript
184 lines
8.7 KiB
JavaScript
// SOLARGOD spacecraft layer (Stage 4) — live positions of real deep-space probes
|
|
// from JPL Horizons via serve.py's proxy. Each craft's VECTORS table (heliocentric
|
|
// J2000 ecliptic, AU) is fetched once over a multi-year span, disk-cached, and
|
|
// Catmull-Rom interpolated per frame. Additive + fail-soft: a craft that won't
|
|
// resolve is skipped with a warn status, never breaking the scene (brief §4, §0).
|
|
|
|
export default function create(ctx) {
|
|
const { THREE, scene, CONFIG, lib, ui, scale, ephem, clock, worldGroup, toLocal, makeLabel } = ctx;
|
|
|
|
const ROSTER = [
|
|
{ id: 'voyager1', name: 'Voyager 1', cmd: '-31', color: '#ff6ad5' },
|
|
{ id: 'voyager2', name: 'Voyager 2', cmd: '-32', color: '#ff9f40' },
|
|
{ id: 'newhorizons', name: 'New Horizons', cmd: '-98', color: '#6fd3ff' },
|
|
{ id: 'jwst', name: 'JWST', cmd: '-170', color: '#ffe14d' },
|
|
{ id: 'parker', name: 'Parker Solar Probe', cmd: '-96', color: '#ff5964' },
|
|
{ id: 'juno', name: 'Juno', cmd: '-61', color: '#8f7bff' },
|
|
{ id: 'lucy', name: 'Lucy', cmd: '-49', color: '#6fcf97' },
|
|
{ id: 'psyche', name: 'Psyche', cmd: '-255', color: '#c9c9c9' },
|
|
];
|
|
const START = '2023-01-01', STOP = '2030-01-01', STEP = '2 d';
|
|
const TRAIL_MAX = 60;
|
|
|
|
const glyphTex = makeGlyph();
|
|
const _abs = {}, _earth = {}, _p = {};
|
|
const crafts = [];
|
|
let show = true;
|
|
|
|
for (const c of ROSTER) {
|
|
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTex, color: new THREE.Color(c.color), transparent: true, depthWrite: false }));
|
|
spr.visible = false;
|
|
spr.userData.craftId = c.id;
|
|
scene.add(spr);
|
|
const tgeo = new THREE.BufferGeometry();
|
|
tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TRAIL_MAX * 3), 3));
|
|
const trail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(c.color), transparent: true, opacity: 0.5 }));
|
|
trail.frustumCulled = false; trail.visible = false;
|
|
worldGroup.add(trail);
|
|
const lbl = makeLabel(spr, c.name, 'craft-label');
|
|
lbl.div.style.color = c.color;
|
|
lbl.obj.visible = false;
|
|
crafts.push({ c, spr, trail, tgeo, lbl, samples: null });
|
|
}
|
|
|
|
ui.addLayer('spacecraft', 'Spacecraft', true, (on) => { show = on; });
|
|
ui.setStatus('spacecraft', 'fetching…', 'warn');
|
|
loadAll();
|
|
|
|
// Sequential (not parallel) — Horizons throttles bursts, and the disk cache
|
|
// makes reloads free anyway (brief §12: don't hammer JPL).
|
|
async function loadAll() {
|
|
let ok = 0, fail = 0;
|
|
for (let i = 0; i < ROSTER.length; i++) {
|
|
const c = ROSTER[i];
|
|
try {
|
|
const s = await fetchCraft(c);
|
|
if (s && s.length > 1) { crafts[i].samples = s; ok++; }
|
|
else fail++;
|
|
} catch (e) { fail++; console.warn('[solargod] spacecraft', c.id, e.message); }
|
|
ui.setStatus('spacecraft', `fetching ${i + 1}/${ROSTER.length}… ${ok} ok`, 'warn');
|
|
}
|
|
ui.setStatus('spacecraft', `${ok} tracked${fail ? `, ${fail} unavailable` : ''} · Horizons ok`, ok ? 'ok' : 'err');
|
|
}
|
|
|
|
// Fetch one craft over [start, stop]. If Horizons rejects the span (a craft's
|
|
// SPK kernel doesn't cover it — e.g. Psyche pre-launch, Juno past coverage), it
|
|
// says exactly where its data begins/ends; parse that and refetch clamped once.
|
|
async function fetchCraft(c, start = START, stop = STOP, retries = 2) {
|
|
const q = {
|
|
format: 'text', COMMAND: `'${c.cmd}'`, OBJ_DATA: 'NO', MAKE_EPHEM: 'YES', EPHEM_TYPE: 'VECTORS',
|
|
CENTER: "'500@10'", START_TIME: `'${start}'`, STOP_TIME: `'${stop}'`, STEP_SIZE: `'${STEP}'`,
|
|
REF_PLANE: 'ECLIPTIC', REF_SYSTEM: 'J2000', VEC_TABLE: '1', OUT_UNITS: 'AU-D', CSV_FORMAT: 'YES',
|
|
};
|
|
const qs = Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
|
const res = await fetch(`${CONFIG.proxy.horizons}?${qs}`);
|
|
const text = await res.text();
|
|
const soe = text.indexOf('$$SOE'), eoe = text.indexOf('$$EOE');
|
|
if (soe < 0 || eoe < 0) {
|
|
if (retries > 0) {
|
|
const prior = text.match(/prior to A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/);
|
|
const after = text.match(/after A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/);
|
|
let ns = start, np = stop, changed = false;
|
|
if (prior) { ns = shiftDate(prior[1], +2); changed = ns !== start; }
|
|
if (after) { np = shiftDate(after[1], -2); changed = changed || np !== stop; }
|
|
if (changed) return fetchCraft(c, ns, np, retries - 1);
|
|
}
|
|
throw new Error('no ephemeris block');
|
|
}
|
|
const out = [];
|
|
for (const line of text.slice(soe + 5, eoe).split('\n')) {
|
|
const p = line.split(',');
|
|
if (p.length < 5) continue;
|
|
const jd = parseFloat(p[0]), x = parseFloat(p[2]), y = parseFloat(p[3]), z = parseFloat(p[4]);
|
|
if (Number.isFinite(jd) && Number.isFinite(x)) out.push({ jd, x, y, z });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// "2023-OCT-13" (+ N days) → "YYYY-MM-DD" for a Horizons START/STOP_TIME.
|
|
function shiftDate(hzn, days) {
|
|
const M = { JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11 };
|
|
const [y, mon, d] = hzn.split('-');
|
|
const dt = new Date(Date.UTC(+y, M[mon.toUpperCase()], +d));
|
|
dt.setUTCDate(dt.getUTCDate() + days);
|
|
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
// Catmull-Rom sample at jd → {x,y,z} AU, or null if jd is outside the span.
|
|
function interp(samples, jd, out) {
|
|
if (jd < samples[0].jd || jd > samples[samples.length - 1].jd) return null;
|
|
let lo = 0, hi = samples.length - 1;
|
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (samples[m].jd <= jd) lo = m; else hi = m; }
|
|
const p1 = samples[lo], p2 = samples[hi];
|
|
const p0 = samples[Math.max(0, lo - 1)], p3 = samples[Math.min(samples.length - 1, hi + 1)];
|
|
const t = (jd - p1.jd) / (p2.jd - p1.jd || 1);
|
|
out.x = catmull(p0.x, p1.x, p2.x, p3.x, t);
|
|
out.y = catmull(p0.y, p1.y, p2.y, p3.y, t);
|
|
out.z = catmull(p0.z, p1.z, p2.z, p3.z, t);
|
|
return out;
|
|
}
|
|
|
|
let lastTextMs = 0;
|
|
return {
|
|
id: 'spacecraft',
|
|
onClockTick(simMs, jd) {
|
|
const camDist = ctx.camera.position.length();
|
|
// label DOM writes throttled ≤5 Hz (brief §11); geometry still updates per frame
|
|
const nowMs = performance.now();
|
|
const updateText = nowMs - lastTextMs > 200;
|
|
if (updateText) lastTextMs = nowMs;
|
|
let earthDone = false;
|
|
for (const cr of crafts) {
|
|
const live = show && cr.samples && interp(cr.samples, jd, _p);
|
|
if (!live) { cr.spr.visible = false; cr.trail.visible = false; cr.lbl.obj.visible = false; continue; }
|
|
if (!earthDone) { ephem.helioEcl('earth', jd, _earth); earthDone = true; }
|
|
cr.spr.visible = true; cr.lbl.obj.visible = true;
|
|
scale.viewFromEcl(_p, _abs);
|
|
toLocal(_abs, cr.spr.position);
|
|
cr.spr.scale.setScalar(Math.max(0.01, camDist * 0.02));
|
|
// trail: up to TRAIL_MAX cached samples before now
|
|
let end = 0; while (end < cr.samples.length && cr.samples[end].jd <= jd) end++;
|
|
const start = Math.max(0, end - TRAIL_MAX);
|
|
const arr = cr.tgeo.attributes.position.array;
|
|
let n = 0;
|
|
for (let i = start; i < end; i++, n++) {
|
|
scale.viewFromEcl(cr.samples[i], _abs);
|
|
arr[n * 3] = _abs.x; arr[n * 3 + 1] = _abs.y; arr[n * 3 + 2] = _abs.z;
|
|
}
|
|
cr.tgeo.setDrawRange(0, n);
|
|
cr.tgeo.attributes.position.needsUpdate = true;
|
|
cr.trail.visible = n > 1;
|
|
if (updateText) {
|
|
const rSun = Math.hypot(_p.x, _p.y, _p.z);
|
|
const rEarth = Math.hypot(_p.x - _earth.x, _p.y - _earth.y, _p.z - _earth.z);
|
|
cr.lbl.div.textContent = `${cr.c.name} · ${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rEarth)} lt`;
|
|
}
|
|
}
|
|
},
|
|
handlePick(raycaster) {
|
|
const markers = crafts.filter((c) => c.spr.visible).map((c) => c.spr);
|
|
const hits = raycaster.intersectObjects(markers, false);
|
|
if (hits.length) {
|
|
const cr = crafts.find((c) => c.spr === hits[0].object);
|
|
if (cr) ui.toast(cr.lbl.div.textContent || cr.c.name);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
|
|
function catmull(p0, p1, p2, p3, t) {
|
|
const t2 = t * t, t3 = t2 * t;
|
|
return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
|
|
}
|
|
function makeGlyph() {
|
|
const s = 64, cv = document.createElement('canvas'); cv.width = cv.height = s;
|
|
const g = cv.getContext('2d');
|
|
g.translate(s / 2, s / 2);
|
|
g.fillStyle = '#fff';
|
|
g.beginPath(); g.moveTo(0, -s * 0.32); g.lineTo(s * 0.32, 0); g.lineTo(0, s * 0.32); g.lineTo(-s * 0.32, 0); g.closePath(); g.fill();
|
|
g.strokeStyle = 'rgba(255,255,255,0.5)'; g.lineWidth = 3; g.beginPath(); g.arc(0, 0, s * 0.42, 0, 7); g.stroke();
|
|
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
|
|
}
|
|
}
|