☄ 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>
This commit is contained in:
parent
5b672f009c
commit
b37eb88670
67
js/layers/asteroids.js
Normal file
67
js/layers/asteroids.js
Normal file
@ -0,0 +1,67 @@
|
||||
// SOLARGOD asteroids layer (Stage 5) — a main-belt sample from JPL SBDB, each rock
|
||||
// propagated by the SAME Kepler core as the planets (ephem.smallBodyEcl over epoch
|
||||
// elements). One Points cloud, positions refreshed ≤10 Hz. Off by default; additive
|
||||
// and fail-soft (brief §5, §11). Gate: the belt sits as a torus between Mars and
|
||||
// Jupiter — not a sphere, not a line.
|
||||
|
||||
export default function create(ctx) {
|
||||
const { THREE, CONFIG, ui, scale, ephem, worldGroup } = ctx;
|
||||
const CAP = 1500;
|
||||
|
||||
let elements = [];
|
||||
let geo = null, points = null, positions = null;
|
||||
let on = false, lastWall = 0;
|
||||
const _e = {}, _v = {};
|
||||
|
||||
ui.addLayer('asteroids', 'Main belt', false, (v) => { on = v; if (points) points.visible = v; if (v) refresh(ctx.clock.jd, true); });
|
||||
ui.setStatus('asteroids', 'off', 'off');
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
ui.setStatus('asteroids', 'fetching SBDB…', 'warn');
|
||||
try {
|
||||
const q = `fields=full_name,a,e,i,om,w,ma,epoch&sb-kind=a&sb-class=MBA&limit=${CAP}`;
|
||||
const j = await (await fetch(`${CONFIG.proxy.sbdb}?${q}`)).json();
|
||||
const F = j.fields, ix = (k) => F.indexOf(k);
|
||||
elements = j.data.map((r) => ({
|
||||
a: +r[ix('a')], e: +r[ix('e')], i: +r[ix('i')], om: +r[ix('om')], w: +r[ix('w')], ma: +r[ix('ma')], epoch: +r[ix('epoch')],
|
||||
})).filter((el) => el.a > 0 && el.e < 0.98 && Number.isFinite(el.a) && Number.isFinite(el.ma));
|
||||
build();
|
||||
ui.setStatus('asteroids', `${elements.length} main-belt · SBDB`, on ? 'ok' : 'off');
|
||||
} catch (err) {
|
||||
console.warn('[solargod] asteroids', err.message);
|
||||
ui.setStatus('asteroids', 'SBDB unavailable', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
geo = new THREE.BufferGeometry();
|
||||
positions = new Float32Array(elements.length * 3);
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
points = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0x9a8464, size: 0.16, sizeAttenuation: true, transparent: true, opacity: 0.85, depthWrite: false }));
|
||||
points.frustumCulled = false;
|
||||
points.visible = on;
|
||||
worldGroup.add(points);
|
||||
if (on) refresh(ctx.clock.jd, true);
|
||||
}
|
||||
|
||||
function refresh(jd) {
|
||||
for (let k = 0; k < elements.length; k++) {
|
||||
ephem.smallBodyEcl(elements[k], jd, _e);
|
||||
scale.viewFromEcl(_e, _v);
|
||||
positions[k * 3] = _v.x; positions[k * 3 + 1] = _v.y; positions[k * 3 + 2] = _v.z;
|
||||
}
|
||||
geo.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'asteroids',
|
||||
onClockTick(simMs, jd) {
|
||||
if (!on || !points) return;
|
||||
const now = performance.now();
|
||||
if (now - lastWall < 100) return; // ≤10 Hz
|
||||
lastWall = now;
|
||||
refresh(jd);
|
||||
},
|
||||
};
|
||||
}
|
||||
123
js/layers/comets.js
Normal file
123
js/layers/comets.js
Normal file
@ -0,0 +1,123 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
86
js/layers/neos.js
Normal file
86
js/layers/neos.js
Normal file
@ -0,0 +1,86 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@ -339,7 +339,8 @@ const ctx = {
|
||||
moonsOf, PLANET_ORDER,
|
||||
};
|
||||
|
||||
const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js', './layers/spacecraft.js'];
|
||||
const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js',
|
||||
'./layers/spacecraft.js', './layers/asteroids.js', './layers/comets.js', './layers/neos.js'];
|
||||
const activeLayers = [];
|
||||
async function loadLayers() {
|
||||
const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p)));
|
||||
|
||||
38
verify.html
38
verify.html
@ -98,6 +98,33 @@
|
||||
scale.setMode('mega'); for (let i = 0; i < 60 && scale.isTransitioning(); i++) scale.update(100);
|
||||
}
|
||||
|
||||
// ---- 5b. Belt sample propagates as a torus between Mars & Jupiter (Stage 5) ----
|
||||
{
|
||||
// three synthetic MBAs (a in 2.2–3.2) at random anomalies must land, in the
|
||||
// compressed view, at radii strictly between Mars's and Jupiter's.
|
||||
const rMars = scale.radial(1.524), rJup = scale.radial(5.203);
|
||||
const els = [
|
||||
{ a: 2.3, e: 0.1, i: 5, om: 30, w: 60, ma: 10, epoch: JD },
|
||||
{ a: 2.77, e: 0.08, i: 10, om: 80, w: 73, ma: 200, epoch: JD },
|
||||
{ a: 3.15, e: 0.15, i: 15, om: 150, w: 200, ma: 300, epoch: JD },
|
||||
];
|
||||
let ok = true, rmin = Infinity, rmax = 0;
|
||||
for (const el of els) {
|
||||
const p = ephem.smallBodyEcl(el, JD, {});
|
||||
const r = scale.radial(Math.hypot(p.x, p.y, p.z));
|
||||
rmin = Math.min(rmin, r); rmax = Math.max(rmax, r);
|
||||
if (!(r > rMars && r < rJup)) ok = false;
|
||||
}
|
||||
row('main belt sits between Mars & Jupiter (view radii)', ok, `belt view-r ${rmin.toFixed(1)}–${rmax.toFixed(1)} ∈ (Mars ${rMars.toFixed(1)}, Jupiter ${rJup.toFixed(1)})`);
|
||||
}
|
||||
|
||||
// ---- 5c. Halley's aphelion reaches beyond Neptune's orbit (Stage 5 gate) ----
|
||||
{
|
||||
const a = 17.93, e = 0.9679; // 1P/Halley (SBDB)
|
||||
const aphelion = a * (1 + e), neptune = 30.07;
|
||||
row("Halley aphelion beyond Neptune", aphelion > neptune, `aphelion ${aphelion.toFixed(1)} AU > Neptune ${neptune} AU`);
|
||||
}
|
||||
|
||||
// ---- 6+7. Live Horizons via proxy — two extra epochs (also gates the proxy) ----
|
||||
// Mars @ J2000.0 (JD 2451545.0) and Jupiter @ 1900-01-01 (JD 2415020.5).
|
||||
async function horizonsVec(command, jd) {
|
||||
@ -162,10 +189,13 @@
|
||||
finish();
|
||||
}
|
||||
finish();
|
||||
liveCheck('Mars @ J2000.0 vs live Horizons', 'mars', '499', 2451545.0, 0.01);
|
||||
liveCheck('Jupiter @ 1900-01-01 vs live Horizons', 'jupiter', '599', 2415020.5, 0.02);
|
||||
craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05);
|
||||
jwstCheck();
|
||||
// Sequential (not parallel) — Horizons throttles bursts of concurrent requests.
|
||||
(async () => {
|
||||
await liveCheck('Mars @ J2000.0 vs live Horizons', 'mars', '499', 2451545.0, 0.01);
|
||||
await liveCheck('Jupiter @ 1900-01-01 vs live Horizons', 'jupiter', '599', 2415020.5, 0.02);
|
||||
await craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05);
|
||||
await jwstCheck();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user