diff --git a/js/layers/asteroids.js b/js/layers/asteroids.js new file mode 100644 index 0000000..b1d58ab --- /dev/null +++ b/js/layers/asteroids.js @@ -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); + }, + }; +} diff --git a/js/layers/comets.js b/js/layers/comets.js new file mode 100644 index 0000000..dbf4752 --- /dev/null +++ b/js/layers/comets.js @@ -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; + } +} diff --git a/js/layers/neos.js b/js/layers/neos.js new file mode 100644 index 0000000..cce40a7 --- /dev/null +++ b/js/layers/neos.js @@ -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; + } +} diff --git a/js/main.js b/js/main.js index 26a05a9..cada6b4 100644 --- a/js/main.js +++ b/js/main.js @@ -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))); diff --git a/verify.html b/verify.html index 40724e5..19ffb6d 100644 --- a/verify.html +++ b/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(); + })();