solargod/js/layers/comets.js
monsterrobotparty b37eb88670 ☄ swarm: small bodies — main belt, comets, NEO close approaches (opus)
Stage 5. asteroids.js: 1500-strong main-belt sample from SBDB (sb-class=MBA),
propagated by the shared Kepler core, one Points cloud refreshed ≤10 Hz, off by
default. comets.js: 1P/2P/67P/96P from SBDB (elliptical only) with full drawn
orbits, marker + anti-sunward tail scaled 1/r², on by default. neos.js: CAD ±30d
close-approach radar around Earth, colored by miss distance, clickable, lunar-
distance labels, off by default. All fail-soft. verify.html gate: belt view-radii
12.1–13.9 ∈ (Mars 10.7, Jupiter 17.4); Halley aphelion 35.3 AU > Neptune. Also
serialized verify's live Horizons checks (burst-throttle fix) → 13/13 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:41:23 +10:00

124 lines
6.0 KiB
JavaScript

// SOLARGOD comets layer (Stage 5) — famous periodic comets from JPL SBDB, drawn
// with their full elliptical orbit (shared Kepler core), a marker, and an
// anti-sunward tail whose length grows as 1/r² near the Sun. Elliptical only
// (e < 0.98). On by default (brief §5). Gate: Halley's aphelion reaches beyond
// Neptune's orbit — visible in the compressed view, near it.
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx;
const WANT = [
{ key: '1P', color: '#8fd3ff' },
{ key: '2P', color: '#ffd24d' },
{ key: '67P', color: '#9d8cff' },
{ key: '96P', color: '#6fcf97' },
];
const N = 512;
const glyph = makeDot();
const comets = [];
let on = true;
const _e = {}, _abs = {}, _dir = {}, _tmp = {};
ui.addLayer('comets', 'Comets', true, (v) => {
on = v;
for (const c of comets) { c.line.visible = v; c.spr.visible = v; c.tail.visible = v; c.lbl.obj.visible = v; }
});
ui.setStatus('comets', 'fetching SBDB…', 'warn');
load();
async function load() {
try {
const j = await (await fetch(`${CONFIG.proxy.sbdb}?fields=full_name,a,e,i,om,w,ma,epoch&sb-kind=c&limit=500`)).json();
const F = j.fields, ix = (k) => F.indexOf(k);
for (const w of WANT) {
const row = j.data.find((r) => { const fn = r[0].trim(); return fn.startsWith(w.key + '/') || fn.startsWith(w.key + ' '); });
if (!row) continue;
const name = row[ix('full_name')].trim();
const el = { a: +row[ix('a')], e: +row[ix('e')], i: +row[ix('i')], om: +row[ix('om')], w: +row[ix('w')], ma: +row[ix('ma')], epoch: +row[ix('epoch')] };
if (!(el.a > 0) || el.e >= 0.98) continue; // elliptical only (brief §3)
build(w, name, el);
}
ui.setStatus('comets', comets.length ? `${comets.length} comets · SBDB` : 'none resolved', comets.length ? 'ok' : 'err');
} catch (err) {
console.warn('[solargod] comets', err.message);
ui.setStatus('comets', 'SBDB unavailable', 'err');
}
}
function build(w, name, el) {
// full orbit (absolute view-world, rides worldGroup)
const au = ephem.orbitPathFromElements(el, N);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3));
const line = new THREE.LineLoop(geo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.5 }));
line.frustumCulled = false; line.visible = on;
worldGroup.add(line);
// marker
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(w.color), transparent: true, depthWrite: false }));
spr.visible = on; scene.add(spr);
// tail: a 2-vertex line, anti-sunward, updated per frame (render-local space)
const tgeo = new THREE.BufferGeometry();
tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const tail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false }));
tail.frustumCulled = false; tail.visible = on; scene.add(tail);
const lbl = makeLabel(spr, name, 'craft-label'); lbl.div.style.color = w.color; lbl.obj.visible = on;
const c = { w, name, el, geo, line, spr, tail, tgeo, lbl, auPath: au, lastT: lib.centuriesSinceJ2000(ctx.clock.jd) };
fillOrbit(c);
comets.push(c);
}
function fillOrbit(c) {
const pos = c.geo.attributes.position.array;
for (let k = 0; k <= N; k++) {
_tmp.x = c.auPath[k * 3]; _tmp.y = c.auPath[k * 3 + 1]; _tmp.z = c.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;
}
c.geo.attributes.position.needsUpdate = true;
c.geo.computeBoundingSphere();
}
return {
id: 'comets',
onClockTick(simMs, jd) {
if (!on) return;
const transitioning = scale.isTransitioning();
const camDist = ctx.camera.position.length();
for (const c of comets) {
ephem.smallBodyEcl(c.el, jd, _e);
const r = Math.hypot(_e.x, _e.y, _e.z);
scale.viewFromEcl(_e, _abs);
toLocal(_abs, c.spr.position);
c.spr.scale.setScalar(Math.max(0.02, camDist * 0.014));
// anti-sunward tail (away from Sun = +unit position), length ∝ 1/r²
const inv = 1 / (r || 1);
lib.eclToWorld({ x: _e.x * inv, y: _e.y * inv, z: _e.z * inv }, _dir);
const len = lib.clamp(3 / (r * r), 0.3, 8);
const tp = c.tgeo.attributes.position.array;
tp[0] = c.spr.position.x; tp[1] = c.spr.position.y; tp[2] = c.spr.position.z;
tp[3] = c.spr.position.x + _dir.x * len; tp[4] = c.spr.position.y + _dir.y * len; tp[5] = c.spr.position.z + _dir.z * len;
c.tgeo.attributes.position.needsUpdate = true;
c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`;
// orbit rebuild on T-drift or scale transition (brief §11)
const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - c.lastT) > CONFIG.orbitRebuildCy;
if (needAu) { c.auPath = ephem.orbitPathFromElements(c.el, N); c.lastT = lib.centuriesSinceJ2000(jd); }
if (needAu || transitioning) fillOrbit(c);
}
},
handlePick(raycaster) {
const hits = raycaster.intersectObjects(comets.filter((c) => c.spr.visible).map((c) => c.spr), false);
if (hits.length) { const c = comets.find((x) => x.spr === hits[0].object); if (c) ui.toast(c.lbl.div.textContent); return true; }
return false;
},
};
function makeDot() {
const s = 48, cv = document.createElement('canvas'); cv.width = cv.height = s;
const g = cv.getContext('2d');
const grd = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
grd.addColorStop(0, '#fff'); grd.addColorStop(0.4, 'rgba(255,255,255,0.7)'); grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd; g.beginPath(); g.arc(s / 2, s / 2, s / 2, 0, 7); g.fill();
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
}
}