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>
87 lines
3.9 KiB
JavaScript
87 lines
3.9 KiB
JavaScript
// SOLARGOD near-Earth close-approach layer (Stage 5) — the CAD API's ±30-day list
|
|
// of close approaches, as highlighted clickable reticles arranged around Earth
|
|
// (a "close-approach radar"), colored by miss distance and labelled in lunar
|
|
// distances. Off by default; fail-soft (brief §5). NOTE: CAD returns approach
|
|
// EVENTS, not orbits — markers are schematic positions around Earth, not tracked
|
|
// trajectories; the data (designation, miss distance, date, speed) is real.
|
|
|
|
export default function create(ctx) {
|
|
const { THREE, scene, CONFIG, lib, ui, ephem, toLocal, makeLabel } = ctx;
|
|
const LD_AU = 384400 / lib.AU_KM; // one lunar distance in AU
|
|
|
|
const glyph = makeReticle();
|
|
const neos = [];
|
|
let on = false;
|
|
const _abs = {};
|
|
|
|
ui.addLayer('neos', 'Close approaches', false, (v) => { on = v; for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; } });
|
|
ui.setStatus('neos', 'off', 'off');
|
|
load();
|
|
|
|
async function load() {
|
|
ui.setStatus('neos', 'fetching CAD…', 'warn');
|
|
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);
|
|
const rows = j.data || [];
|
|
rows.forEach((r, i) => build({
|
|
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')],
|
|
}, i, rows.length));
|
|
ui.setStatus('neos', neos.length ? `${neos.length} approaches ±30d · CAD` : 'none in window', on ? 'ok' : 'off');
|
|
} catch (err) {
|
|
console.warn('[solargod] neos', err.message);
|
|
ui.setStatus('neos', 'CAD unavailable', 'err');
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
return {
|
|
id: 'neos',
|
|
onClockTick() {
|
|
if (!on) return;
|
|
const earth = ctx.bodyWorld.earth;
|
|
if (!earth) return;
|
|
const camDist = ctx.camera.position.length();
|
|
const ringR = camDist * 0.06;
|
|
for (const n of neos) {
|
|
_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;
|
|
}
|
|
}
|