From 6084acad0f71e210e29a413d896e845a6601d804 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Thu, 16 Jul 2026 00:32:44 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=B0=20probes:=20the=20live=20layer=20?= =?UTF-8?q?=E2=80=94=208=20spacecraft=20via=20Horizons,=20trails=20+=20lig?= =?UTF-8?q?ht-time=20(opus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4. spacecraft.js: Voyager 1/2, New Horizons, JWST, Parker, Juno, Lucy, Psyche. Fetches VECTORS (heliocentric ecliptic AU) over a multi-year span through serve.py's cache, Catmull-Rom interpolated per frame; glowing diamond marker + fading trail + label with live distance from Sun and one-way light-time from Earth. Fail-soft per craft. Robustness: sequential fetch (Horizons throttles bursts), serve.py skips caching non-$$SOE (error) responses, and span auto-recovery clamps START/STOP to a craft's SPK coverage from Horizons' own "prior to / after" hints (Psyche pre-launch, Juno past coverage) → 8/8 tracked. Gate: Voyager 1 @2026-07-15 = (-32.07,-136.21,98.55) Δ0.002 AU; JWST Δ0.011 AU from Earth. verify.html gates 10-11 green. Co-Authored-By: Claude Opus 4.8 --- css/style.css | 5 ++ js/layers/spacecraft.js | 176 ++++++++++++++++++++++++++++++++++++++++ js/main.js | 2 +- serve.py | 6 +- verify.html | 26 ++++++ 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 js/layers/spacecraft.js diff --git a/css/style.css b/css/style.css index 4a9f854..7d6f77d 100644 --- a/css/style.css +++ b/css/style.css @@ -44,6 +44,11 @@ html, body { .body-label.moon { color: var(--dim); font-size: 9.5px; } .body-label .lbl-dist { color: var(--brass-dim); font-size: 9px; } .body-label.focused { color: var(--brass); font-weight: 700; } +.craft-label { + font-size: 9.5px; letter-spacing: 0.3px; white-space: nowrap; + text-shadow: 0 0 4px #000, 0 0 8px #000; transform: translate(-50%, -180%); + user-select: none; padding: 1px 3px; +} /* ---- breadcrumb ---- */ #breadcrumb { diff --git a/js/layers/spacecraft.js b/js/layers/spacecraft.js new file mode 100644 index 0000000..3685045 --- /dev/null +++ b/js/layers/spacecraft.js @@ -0,0 +1,176 @@ +// SOLARGOD spacecraft layer (Stage 4) — live positions of real deep-space probes +// from JPL Horizons via serve.py's proxy. Each craft's VECTORS table (heliocentric +// J2000 ecliptic, AU) is fetched once over a multi-year span, disk-cached, and +// Catmull-Rom interpolated per frame. Additive + fail-soft: a craft that won't +// resolve is skipped with a warn status, never breaking the scene (brief §4, §0). + +export default function create(ctx) { + const { THREE, scene, CONFIG, lib, ui, scale, ephem, clock, worldGroup, toLocal, makeLabel } = ctx; + + const ROSTER = [ + { id: 'voyager1', name: 'Voyager 1', cmd: '-31', color: '#ff6ad5' }, + { id: 'voyager2', name: 'Voyager 2', cmd: '-32', color: '#ff9f40' }, + { id: 'newhorizons', name: 'New Horizons', cmd: '-98', color: '#6fd3ff' }, + { id: 'jwst', name: 'JWST', cmd: '-170', color: '#ffe14d' }, + { id: 'parker', name: 'Parker Solar Probe', cmd: '-96', color: '#ff5964' }, + { id: 'juno', name: 'Juno', cmd: '-61', color: '#8f7bff' }, + { id: 'lucy', name: 'Lucy', cmd: '-49', color: '#6fcf97' }, + { id: 'psyche', name: 'Psyche', cmd: '-255', color: '#c9c9c9' }, + ]; + const START = '2023-01-01', STOP = '2030-01-01', STEP = '2 d'; + const TRAIL_MAX = 60; + + const glyphTex = makeGlyph(); + const _abs = {}, _earth = {}, _p = {}; + const crafts = []; + let show = true; + + for (const c of ROSTER) { + const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTex, color: new THREE.Color(c.color), transparent: true, depthWrite: false })); + spr.visible = false; + spr.userData.craftId = c.id; + scene.add(spr); + const tgeo = new THREE.BufferGeometry(); + tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TRAIL_MAX * 3), 3)); + const trail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(c.color), transparent: true, opacity: 0.5 })); + trail.frustumCulled = false; trail.visible = false; + worldGroup.add(trail); + const lbl = makeLabel(spr, c.name, 'craft-label'); + lbl.div.style.color = c.color; + lbl.obj.visible = false; + crafts.push({ c, spr, trail, tgeo, lbl, samples: null }); + } + + ui.addLayer('spacecraft', 'Spacecraft', true, (on) => { show = on; }); + ui.setStatus('spacecraft', 'fetching…', 'warn'); + loadAll(); + + // Sequential (not parallel) — Horizons throttles bursts, and the disk cache + // makes reloads free anyway (brief §12: don't hammer JPL). + async function loadAll() { + let ok = 0, fail = 0; + for (let i = 0; i < ROSTER.length; i++) { + const c = ROSTER[i]; + try { + const s = await fetchCraft(c); + if (s && s.length > 1) { crafts[i].samples = s; ok++; } + else fail++; + } catch (e) { fail++; console.warn('[solargod] spacecraft', c.id, e.message); } + ui.setStatus('spacecraft', `fetching ${i + 1}/${ROSTER.length}… ${ok} ok`, 'warn'); + } + ui.setStatus('spacecraft', `${ok} tracked${fail ? `, ${fail} unavailable` : ''} · Horizons ok`, ok ? 'ok' : 'err'); + } + + // Fetch one craft over [start, stop]. If Horizons rejects the span (a craft's + // SPK kernel doesn't cover it — e.g. Psyche pre-launch, Juno past coverage), it + // says exactly where its data begins/ends; parse that and refetch clamped once. + async function fetchCraft(c, start = START, stop = STOP, retries = 2) { + const q = { + format: 'text', COMMAND: `'${c.cmd}'`, OBJ_DATA: 'NO', MAKE_EPHEM: 'YES', EPHEM_TYPE: 'VECTORS', + CENTER: "'500@10'", START_TIME: `'${start}'`, STOP_TIME: `'${stop}'`, STEP_SIZE: `'${STEP}'`, + REF_PLANE: 'ECLIPTIC', REF_SYSTEM: 'J2000', VEC_TABLE: '1', OUT_UNITS: 'AU-D', CSV_FORMAT: 'YES', + }; + const qs = Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); + const res = await fetch(`${CONFIG.proxy.horizons}?${qs}`); + const text = await res.text(); + const soe = text.indexOf('$$SOE'), eoe = text.indexOf('$$EOE'); + if (soe < 0 || eoe < 0) { + if (retries > 0) { + const prior = text.match(/prior to A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/); + const after = text.match(/after A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/); + let ns = start, np = stop, changed = false; + if (prior) { ns = shiftDate(prior[1], +2); changed = ns !== start; } + if (after) { np = shiftDate(after[1], -2); changed = changed || np !== stop; } + if (changed) return fetchCraft(c, ns, np, retries - 1); + } + throw new Error('no ephemeris block'); + } + const out = []; + for (const line of text.slice(soe + 5, eoe).split('\n')) { + const p = line.split(','); + if (p.length < 5) continue; + const jd = parseFloat(p[0]), x = parseFloat(p[2]), y = parseFloat(p[3]), z = parseFloat(p[4]); + if (Number.isFinite(jd) && Number.isFinite(x)) out.push({ jd, x, y, z }); + } + return out; + } + + // "2023-OCT-13" (+ N days) → "YYYY-MM-DD" for a Horizons START/STOP_TIME. + function shiftDate(hzn, days) { + const M = { JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11 }; + const [y, mon, d] = hzn.split('-'); + const dt = new Date(Date.UTC(+y, M[mon.toUpperCase()], +d)); + dt.setUTCDate(dt.getUTCDate() + days); + return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`; + } + + // Catmull-Rom sample at jd → {x,y,z} AU, or null if jd is outside the span. + function interp(samples, jd, out) { + if (jd < samples[0].jd || jd > samples[samples.length - 1].jd) return null; + let lo = 0, hi = samples.length - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (samples[m].jd <= jd) lo = m; else hi = m; } + const p1 = samples[lo], p2 = samples[hi]; + const p0 = samples[Math.max(0, lo - 1)], p3 = samples[Math.min(samples.length - 1, hi + 1)]; + const t = (jd - p1.jd) / (p2.jd - p1.jd || 1); + out.x = catmull(p0.x, p1.x, p2.x, p3.x, t); + out.y = catmull(p0.y, p1.y, p2.y, p3.y, t); + out.z = catmull(p0.z, p1.z, p2.z, p3.z, t); + return out; + } + + return { + id: 'spacecraft', + onClockTick(simMs, jd) { + const camDist = ctx.camera.position.length(); + let earthDone = false; + for (const cr of crafts) { + const live = show && cr.samples && interp(cr.samples, jd, _p); + if (!live) { cr.spr.visible = false; cr.trail.visible = false; cr.lbl.obj.visible = false; continue; } + if (!earthDone) { ephem.helioEcl('earth', jd, _earth); earthDone = true; } + cr.spr.visible = true; cr.lbl.obj.visible = true; + scale.viewFromEcl(_p, _abs); + toLocal(_abs, cr.spr.position); + cr.spr.scale.setScalar(Math.max(0.01, camDist * 0.02)); + // trail: up to TRAIL_MAX cached samples before now + let end = 0; while (end < cr.samples.length && cr.samples[end].jd <= jd) end++; + const start = Math.max(0, end - TRAIL_MAX); + const arr = cr.tgeo.attributes.position.array; + let n = 0; + for (let i = start; i < end; i++, n++) { + scale.viewFromEcl(cr.samples[i], _abs); + arr[n * 3] = _abs.x; arr[n * 3 + 1] = _abs.y; arr[n * 3 + 2] = _abs.z; + } + cr.tgeo.setDrawRange(0, n); + cr.tgeo.attributes.position.needsUpdate = true; + cr.trail.visible = n > 1; + const rSun = Math.hypot(_p.x, _p.y, _p.z); + const rEarth = Math.hypot(_p.x - _earth.x, _p.y - _earth.y, _p.z - _earth.z); + cr.lbl.div.textContent = `${cr.c.name} · ${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rEarth)} lt`; + } + }, + handlePick(raycaster) { + const markers = crafts.filter((c) => c.spr.visible).map((c) => c.spr); + const hits = raycaster.intersectObjects(markers, false); + if (hits.length) { + const cr = crafts.find((c) => c.spr === hits[0].object); + if (cr) ui.toast(cr.lbl.div.textContent || cr.c.name); + return true; + } + return false; + }, + }; + + function catmull(p0, p1, p2, p3, t) { + const t2 = t * t, t3 = t2 * t; + return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); + } + function makeGlyph() { + const s = 64, cv = document.createElement('canvas'); cv.width = cv.height = s; + const g = cv.getContext('2d'); + g.translate(s / 2, s / 2); + g.fillStyle = '#fff'; + g.beginPath(); g.moveTo(0, -s * 0.32); g.lineTo(s * 0.32, 0); g.lineTo(0, s * 0.32); g.lineTo(-s * 0.32, 0); g.closePath(); g.fill(); + g.strokeStyle = 'rgba(255,255,255,0.5)'; g.lineWidth = 3; g.beginPath(); g.arc(0, 0, s * 0.42, 0, 7); 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 4163618..26a05a9 100644 --- a/js/main.js +++ b/js/main.js @@ -339,7 +339,7 @@ const ctx = { moonsOf, PLANET_ORDER, }; -const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js']; +const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js', './layers/spacecraft.js']; const activeLayers = []; async function loadLayers() { const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p))); diff --git a/serve.py b/serve.py index 5bfb6cd..dae802a 100644 --- a/serve.py +++ b/serve.py @@ -169,7 +169,11 @@ class Handler(SimpleHTTPRequestHandler): with urllib.request.urlopen(req, timeout=60) as r: body = r.read() ctype = r.headers.get("Content-Type", "text/plain") - _cache_write(url, body, ctype, _requested_span_is_past(name, query)) + # Horizons returns HTTP 200 even for "no ephemeris" errors — don't cache + # those (a transient/burst failure would otherwise poison the cache). A + # real ephemeris always contains the $$SOE marker. + if name != "horizons" or b"$$SOE" in body: + _cache_write(url, body, ctype, _requested_span_is_past(name, query)) return self._send(200, ctype, body, {"X-Solargod-Cache": "miss"}) except urllib.error.HTTPError as e: body = e.read() or str(e).encode() diff --git a/verify.html b/verify.html index dc5d2e7..40724e5 100644 --- a/verify.html +++ b/verify.html @@ -137,9 +137,35 @@ s.textContent = `${passed}/${n} gates pass` + (failed ? ` — ${failed} FAILING` : ' — ALL PASS ✦'); s.className = failed ? 'fail' : 'pass'; } + // ---- 10. Voyager 1 (-31) @ 2026-07-15 ≈ (−32.07, −136.21, +98.55) AU (brief §9) ---- + async function craftCheck(label, command, jd, want, tol) { + const tr = pendingRow(label); + try { + const got = await horizonsVec(command, jd); + const worst = Math.max(Math.abs(got.x - want.x), Math.abs(got.y - want.y), Math.abs(got.z - want.z)); + tr.remove(); + row(label, worst <= tol, `Δmax=${worst.toFixed(3)} AU (tol ${tol}) · Horizons (${got.x.toFixed(2)}, ${got.y.toFixed(2)}, ${got.z.toFixed(2)}) · ${Math.hypot(got.x,got.y,got.z).toFixed(1)} AU out`); + } catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); } + finish(); + } + // ---- 11. JWST (-170) hugs Earth (Δ < 0.02 AU) ---- + async function jwstCheck() { + const label = 'JWST (-170) hugs Earth Δ<0.02 AU'; + const tr = pendingRow(label); + try { + const j = await horizonsVec('-170', 2461236.5); + const e = await horizonsVec('399', 2461236.5); + const d = Math.hypot(j.x - e.x, j.y - e.y, j.z - e.z); + tr.remove(); + row(label, d < 0.02, `Δ=${(d).toFixed(4)} AU from Earth (${(d*lib.AU_KM/1e6).toFixed(2)}M km)`); + } catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); } + 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();