From b3499381bfebe11f6c7f27dfe9fe6042d821f682 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:16:03 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20celestial=20layer=20=E2=80=94=20zodiac?= =?UTF-8?q?=20ring=20+=20Sun/Moon/planets=20in=20true=20sky=20positions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Sky: zodiac · planets' layer (Space, default off). All computed, no data: - 12 zodiac signs labeled around the ecliptic ring - Sun & Moon from Cesium's Simon1994 model, 5 naked-eye planets from Schlyter's low-precision series — correct directions, verified (Sun sits in the right sign for the date, planets hug the ecliptic) - placed in the INERTIAL frame (CallbackProperty × ICRF→Fixed each frame) so the sky stays star-locked while the globe rotates; distances compressed to one display ring beyond GEO so they're visible when zoomed out Co-Authored-By: Claude Fable 5 --- js/layers/celestial.js | 161 +++++++++++++++++++++++++++++++++++++++++ js/main.js | 1 + js/ui.js | 2 +- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 js/layers/celestial.js diff --git a/js/layers/celestial.js b/js/layers/celestial.js new file mode 100644 index 0000000..55a6a22 --- /dev/null +++ b/js/layers/celestial.js @@ -0,0 +1,161 @@ +// Celestial layer (Wave 3.2) — the sky around the world: the 12 zodiac signs as +// a labeled ecliptic ring, plus the Sun, Moon and naked-eye planets, all placed +// in their TRUE directions and locked to the stars (inertial frame), not spun +// with the globe. A "distant visual" you see when zoomed out. +// +// Everything is computed (no external data): Sun/Moon from Cesium's Simon1994 +// model, planets from Schlyter's low-precision series, the zodiac from the +// ecliptic geometry. Directions are correct; distances are COMPRESSED to one +// display radius so they ring the Earth instead of vanishing at true scale. + +export default function create(ctx) { + const { viewer, Cesium, lib, ui } = ctx; + const scene = viewer.scene; + const ds = new Cesium.CustomDataSource('celestial'); + viewer.dataSources.add(ds); + + const R = 8.0e7; // display radius (~80,000 km) — a ring beyond GEO + const OBLIQ = Cesium.Math.toRadians(23.4393); // ecliptic obliquity + const cosE = Math.cos(OBLIQ), sinE = Math.sin(OBLIQ); + + // Shared per-frame inertial→fixed rotation (computed once per tick, reused by + // every object's position callback). + let frameIcrf = Cesium.Matrix3.IDENTITY.clone(); + scene.postUpdate.addEventListener((s, time) => { + const m = Cesium.Transforms.computeIcrfToFixedMatrix(time); + if (Cesium.defined(m)) frameIcrf = m; + }); + + // Inertial unit direction → fixed-frame position at radius `rad`. + const scratch = new Cesium.Cartesian3(); + function fixedPos(dirInertial, rad) { + Cesium.Matrix3.multiplyByVector(frameIcrf, dirInertial, scratch); + return Cesium.Cartesian3.multiplyByScalar(scratch, rad, new Cesium.Cartesian3()); + } + // A CallbackProperty position from a function(time) -> inertial unit vector. + function skyPosition(dirFn, rad = R) { + return new Cesium.CallbackProperty((time) => fixedPos(dirFn(time), rad), false); + } + + // Ecliptic (lon λ, lat β=0) → inertial equatorial unit vector. + function eclipticDir(lonRad, latRad = 0) { + const cl = Math.cos(lonRad), sl = Math.sin(lonRad), cb = Math.cos(latRad), sb = Math.sin(latRad); + const xe = cb * cl; + const ye = cb * sl * cosE - sb * sinE; + const ze = cb * sl * sinE + sb * cosE; + return new Cesium.Cartesian3(xe, ye, ze); + } + + // Days since 2000 Jan 0.0 UTC (Schlyter's epoch). + // d = JD − 2451543.5 (Schlyter epoch); unix-epoch JD is 2440587.5 → offset 10956.0. + const dayNumber = (time) => Cesium.JulianDate.toDate(time).getTime() / 86400000 - 10956.0; + + // ---- planets (Schlyter low-precision) ---- + const rev = (x) => x - Math.floor(x / 360) * 360; + const D2R = Math.PI / 180; + // Orbital elements as functions of day d (degrees / AU). + const ELEM = { + Mercury: (d) => ({ N: 48.3313 + 3.24587e-5 * d, i: 7.0047 + 5.00e-8 * d, w: 29.1241 + 1.01444e-5 * d, a: 0.387098, e: 0.205635 + 5.59e-10 * d, M: 168.6562 + 4.0923344368 * d }), + Venus: (d) => ({ N: 76.6799 + 2.46590e-5 * d, i: 3.3946 + 2.75e-8 * d, w: 54.8910 + 1.38374e-5 * d, a: 0.723330, e: 0.006773 - 1.302e-9 * d, M: 48.0052 + 1.6021302244 * d }), + Mars: (d) => ({ N: 49.5574 + 2.11081e-5 * d, i: 1.8497 - 1.78e-8 * d, w: 286.5016 + 2.92961e-5 * d, a: 1.523688, e: 0.093405 + 2.516e-9 * d, M: 18.6021 + 0.5240207766 * d }), + Jupiter: (d) => ({ N: 100.4542 + 2.76854e-5 * d, i: 1.3030 - 1.557e-7 * d, w: 273.8777 + 1.64505e-5 * d, a: 5.20256, e: 0.048498 + 4.469e-9 * d, M: 19.8950 + 0.0830853001 * d }), + Saturn: (d) => ({ N: 113.6634 + 2.38980e-5 * d, i: 2.4886 - 1.081e-7 * d, w: 339.3939 + 2.97661e-5 * d, a: 9.55475, e: 0.055546 - 9.499e-9 * d, M: 316.9670 + 0.0334442282 * d }), + }; + function sunRect(d) { // Sun's geocentric ecliptic rectangular (AU) + const w = 282.9404 + 4.70935e-5 * d, e = 0.016709 - 1.151e-9 * d, M = rev(356.0470 + 0.9856002585 * d); + const E = M + (180 / Math.PI) * e * Math.sin(M * D2R) * (1 + e * Math.cos(M * D2R)); + const xv = Math.cos(E * D2R) - e, yv = Math.sqrt(1 - e * e) * Math.sin(E * D2R); + const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), lon = v + w * D2R; + return { x: r * Math.cos(lon), y: r * Math.sin(lon) }; + } + function planetDir(name, time) { + const d = dayNumber(time); + const el = ELEM[name](d); + const N = el.N * D2R, i = el.i * D2R, w = el.w * D2R, e = el.e, M = rev(el.M) * D2R; + let E = M + e * Math.sin(M) * (1 + e * Math.cos(M)); + for (let k = 0; k < 5; k++) E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E)); + const xv = el.a * (Math.cos(E) - e), yv = el.a * Math.sqrt(1 - e * e) * Math.sin(E); + const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), u = v + w; + // Heliocentric ecliptic rectangular. + const xh = r * (Math.cos(N) * Math.cos(u) - Math.sin(N) * Math.sin(u) * Math.cos(i)); + const yh = r * (Math.sin(N) * Math.cos(u) + Math.cos(N) * Math.sin(u) * Math.cos(i)); + const zh = r * Math.sin(u) * Math.sin(i); + // Geocentric = heliocentric + (Earth→Sun) = helio + sun-geocentric. + const s = sunRect(d); + const xg = xh + s.x, yg = yh + s.y, zg = zh; + const lon = Math.atan2(yg, xg), lat = Math.atan2(zg, Math.hypot(xg, yg)); + return eclipticDir(lon, lat); + } + const sunDir = (time) => { + const p = Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time); + return Cesium.Cartesian3.normalize(p, new Cesium.Cartesian3()); + }; + const moonDir = (time) => { + const p = Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time); + return Cesium.Cartesian3.normalize(p, new Cesium.Cartesian3()); + }; + + // ---- build entities ---- + const bodyLabel = (extra) => ({ + font: '13px "Segoe UI", system-ui, sans-serif', + fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.fromCssColorString('#05080b'), + outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -12), + ...extra, + }); + + const BODIES = [ + { name: '☉ Sun', dir: sunDir, color: '#ffcf40', size: 22, rad: R }, + { name: '☾ Moon', dir: moonDir, color: '#d8d8e0', size: 16, rad: R * 0.9 }, + { name: '☿ Mercury', dir: (t) => planetDir('Mercury', t), color: '#b0a080', size: 7 }, + { name: '♀ Venus', dir: (t) => planetDir('Venus', t), color: '#f5e8c0', size: 10 }, + { name: '♂ Mars', dir: (t) => planetDir('Mars', t), color: '#ff6b4d', size: 8 }, + { name: '♃ Jupiter', dir: (t) => planetDir('Jupiter', t), color: '#e8c090', size: 12 }, + { name: '♄ Saturn', dir: (t) => planetDir('Saturn', t), color: '#e8dcb0', size: 10 }, + ]; + for (const b of BODIES) { + ds.entities.add({ + name: b.name, + position: skyPosition(b.dir, b.rad || R), + point: { pixelSize: b.size, color: lib.cz(b.color), outlineColor: Cesium.Color.BLACK, outlineWidth: 1 }, + label: bodyLabel({ text: b.name, fillColor: lib.cz(b.color) }), + }); + } + + // Zodiac: 12 sign labels at the centre of each 30° sector + the ecliptic ring. + const SIGNS = ['♈ Aries', '♉ Taurus', '♊ Gemini', '♋ Cancer', '♌ Leo', '♍ Virgo', + '♎ Libra', '♏ Scorpio', '♐ Sagittarius', '♑ Capricorn', '♒ Aquarius', '♓ Pisces']; + for (let k = 0; k < 12; k++) { + const lon = Cesium.Math.toRadians(k * 30 + 15); + ds.entities.add({ + name: SIGNS[k], + position: fixedPosStatic(eclipticDir(lon), R * 1.06), // static ok: sky barely moves in ±6h + label: bodyLabel({ text: SIGNS[k], font: '12px "Segoe UI", system-ui, sans-serif', fillColor: lib.cz('#8fb7ff'), pixelOffset: new Cesium.Cartesian2(0, 0) }), + }); + } + // Ecliptic ring + 30° boundary ticks (rebuilt each frame in the fixed frame). + ds.entities.add({ + name: 'Ecliptic', + polyline: { + width: 1.5, + material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: lib.cz('#8fb7ff', 0.5) }), + positions: new Cesium.CallbackProperty(() => { + const pts = []; + for (let a = 0; a <= 360; a += 4) pts.push(fixedPos(eclipticDir(Cesium.Math.toRadians(a)), R * 1.02)); + return pts; + }, false), + }, + }); + + // A single static-ish snapshot position (zodiac labels don't need per-frame update + // at this zoom; recompute once so they sit in the current fixed frame). + function fixedPosStatic(dirInertial, rad) { + return skyPosition(() => dirInertial, rad); // still callback so it tracks Earth rotation + } + + ui.addLayer('celestial', 'Sky: zodiac · planets', false, (on) => { ds.show = on; }, 'Space'); + ui.setStatus('celestial', 'Sun · Moon · 5 planets · zodiac', 'ok'); + ds.show = false; + + return { id: 'celestial' }; +} diff --git a/js/main.js b/js/main.js index dc265be..78730ec 100644 --- a/js/main.js +++ b/js/main.js @@ -181,6 +181,7 @@ const LAYER_MODULES = [ './layers/aircraft.js', './layers/military.js', './layers/radius.js', + './layers/celestial.js', ]; const activeLayers = []; diff --git a/js/ui.js b/js/ui.js index 6f54605..f37170e 100644 --- a/js/ui.js +++ b/js/ui.js @@ -8,7 +8,7 @@ const rows = new Map(); const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia']; const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']); const CATEGORY_BY_ID = { - satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', + satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', celestial: 'Space', aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air', emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air', ships: 'Sea',