solargod/js/layers/neos.js
monsterrobotparty b42473a055 🔭 review: post-merge integration — sbdb_lookup into CONFIG.proxy, era-aware BC dates in lib formatters (fable)
Merged suite: 21/21 gates in-browser incl. NEO real-orbit (err 1.16%),
Jupiter@1000BC Δ0.004 AU, 2b-proof. Deep-time readout now '1000 BC-01-15'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:32:38 +10:00

161 lines
7.3 KiB
JavaScript

// SOLARGOD near-Earth close-approach layer (Stage 5 · wave 2 NEARGOD) — the CAD
// API's ±30-day list of close approaches, each object now drawn at its TRUE
// propagated position with its REAL orbit. CAD gives designations, miss distance,
// date, speed; SBDB's lookup endpoint (sbdb.api, full precision) gives the
// osculating elements, propagated by the SAME shared Kepler core as the belt
// (ephem.smallBodyEcl). Off by default; additive and fail-soft PER OBJECT — a
// lookup that won't resolve falls back to the wave-1 schematic ring for that one
// object, and the status line reports the split honestly (brief §5, Lane N).
// Parse an SBDB lookup response → {a,e,i,om,w,ma,epoch} (deg/AU/JD) or null.
// Elements arrive as an array of {name, value} STRINGS; epoch is orbit.epoch (JD,
// TDB). Reject non-finite or e≥0.98 (the wave-1 elliptical-only rule, brief §3).
// Exported so verify.html's gate exercises the identical parser.
export function parseSbdbElements(j) {
const o = j && j.orbit;
if (!o || !Array.isArray(o.elements)) return null;
const m = {};
for (const e of o.elements) m[e.name] = Number(e.value);
const el = { a: m.a, e: m.e, i: m.i, om: m.om, w: m.w, ma: m.ma, epoch: Number(o.epoch) };
for (const v of Object.values(el)) if (!Number.isFinite(v)) return null;
if (el.e >= 0.98 || !(el.a > 0)) return null;
return el;
}
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx;
const LD_AU = 384400 / lib.AU_KM; // one lunar distance in AU
const SBDB_LOOKUP = CONFIG.proxy.sbdbLookup;
const N = 256; // orbit samples per NEO
const glyph = makeReticle();
const neos = [];
let on = false;
const _e = {}, _abs = {}, _tmp = {};
ui.addLayer('neos', 'Close approaches', false, (v) => {
on = v;
for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; if (n.line) n.line.visible = v; }
});
ui.setStatus('neos', 'off', 'off');
load();
async function load() {
ui.setStatus('neos', 'fetching CAD…', 'warn');
let rows;
try {
const now = new Date(ctx.clock.simMs);
const fmt = (d) => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const dmin = fmt(new Date(now.getTime() - 30 * 86400000));
const dmax = fmt(new Date(now.getTime() + 30 * 86400000));
const j = await (await fetch(`${CONFIG.proxy.cad}?date-min=${dmin}&date-max=${dmax}&dist-max=0.05&sort=dist&limit=40`)).json();
const F = j.fields, ix = (k) => F.indexOf(k);
rows = (j.data || []).map((r) => ({
des: r[ix('des')], jd: +r[ix('jd')], cd: r[ix('cd')], dist: +r[ix('dist')],
v: +r[ix('v_rel')], h: +r[ix('h')],
}));
} catch (err) {
console.warn('[solargod] neos CAD', err.message);
ui.setStatus('neos', 'CAD unavailable', 'err');
return;
}
// Build every marker up-front (schematic ring by default), then upgrade each to
// a true orbit as its SBDB lookup lands. SEQUENTIAL — sbdb.api burst-throttles,
// and the disk cache makes reloads instant (brief §12: don't hammer JPL).
rows.forEach((d, i) => build(d, i, rows.length));
let real = 0, sched = 0;
for (let i = 0; i < neos.length; i++) {
const n = neos[i];
try {
const res = await fetch(`${SBDB_LOOKUP}?sstr=${encodeURIComponent(n.d.des)}&full-prec=true`);
const el = parseSbdbElements(await res.json());
if (el) { attachOrbit(n, el); real++; } else sched++;
} catch (err) { sched++; console.warn('[solargod] neos', n.d.des, err.message); }
ui.setStatus('neos', `fetching ${i + 1}/${neos.length}${real} real`, 'warn');
}
ui.setStatus('neos',
neos.length ? `${real} real orbits · ${sched} schematic · CAD+SBDB` : 'none in window',
on ? 'ok' : 'off');
}
function build(d, i, total) {
const ld = d.dist / LD_AU;
const col = ld < 1 ? '#ff4d4d' : ld < 5 ? '#ffb347' : '#7bff9d';
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(col), transparent: true, depthWrite: false }));
spr.visible = on; scene.add(spr);
const lbl = makeLabel(spr, `${d.des} · ${ld.toFixed(1)} LD`, 'craft-label'); lbl.div.style.color = col; lbl.obj.visible = on;
neos.push({ d, ld, col, spr, lbl, angle: (i / Math.max(1, total)) * Math.PI * 2, el: null, line: null, geo: null, auPath: null, lastFillP: NaN });
}
// Attach the real elliptical orbit (rides worldGroup in absolute view-world, like
// the comets). Refilled from the frozen AU path whenever the MEGA/TRUE P changes.
function attachOrbit(n, el) {
n.el = el;
n.auPath = ephem.orbitPathFromElements(el, N);
n.geo = new THREE.BufferGeometry();
n.geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3));
n.line = new THREE.LineLoop(n.geo, new THREE.LineBasicMaterial({ color: new THREE.Color(n.col), transparent: true, opacity: 0.25 }));
n.line.frustumCulled = false; n.line.visible = on;
worldGroup.add(n.line);
fillOrbit(n);
}
function fillOrbit(n) {
const pos = n.geo.attributes.position.array;
for (let k = 0; k <= N; k++) {
_tmp.x = n.auPath[k * 3]; _tmp.y = n.auPath[k * 3 + 1]; _tmp.z = n.auPath[k * 3 + 2];
scale.viewFromEcl(_tmp, _tmp);
pos[k * 3] = _tmp.x; pos[k * 3 + 1] = _tmp.y; pos[k * 3 + 2] = _tmp.z;
}
n.geo.attributes.position.needsUpdate = true;
n.geo.computeBoundingSphere();
n.lastFillP = scale.getP();
}
return {
id: 'neos',
onClockTick(simMs, jd) {
if (!on) return;
const camDist = ctx.camera.position.length();
const P = scale.getP();
const earth = ctx.bodyWorld.earth;
const ringR = camDist * 0.06;
for (const n of neos) {
if (n.el) {
// true propagated position (same core as the belt)
ephem.smallBodyEcl(n.el, jd, _e);
scale.viewFromEcl(_e, _abs);
toLocal(_abs, n.spr.position);
if (P !== n.lastFillP) fillOrbit(n); // track the MEGA↔TRUE tween
} else if (earth) {
// schematic ring fallback for THIS object alone (wave-1 placement)
_abs.x = earth.x; _abs.y = earth.y; _abs.z = earth.z;
toLocal(_abs, n.spr.position);
n.spr.position.x += Math.cos(n.angle) * ringR;
n.spr.position.y += Math.sin(n.angle) * ringR * 0.5;
}
n.spr.scale.setScalar(Math.max(0.02, camDist * 0.012));
}
},
handlePick(raycaster) {
const hits = raycaster.intersectObjects(neos.filter((n) => n.spr.visible).map((n) => n.spr), false);
if (hits.length) {
const n = neos.find((x) => x.spr === hits[0].object);
if (n) ui.toast(`${n.d.des} · ${n.ld.toFixed(2)} LD · ${n.d.cd} · ${n.d.v.toFixed(1)} km/s`);
return true;
}
return false;
},
};
function makeReticle() {
const s = 48, cv = document.createElement('canvas'); cv.width = cv.height = s;
const g = cv.getContext('2d');
g.strokeStyle = '#fff'; g.lineWidth = 3;
g.beginPath(); g.arc(s / 2, s / 2, s * 0.32, 0, 7); g.stroke();
g.beginPath(); g.moveTo(s / 2, 2); g.lineTo(s / 2, s * 0.18); g.moveTo(s / 2, s - 2); g.lineTo(s / 2, s * 0.82); g.stroke();
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
}
}