feat: celestial layer — zodiac ring + Sun/Moon/planets in true sky positions

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:16:03 +10:00
parent 2a51bcad2f
commit b3499381bf
3 changed files with 163 additions and 1 deletions

161
js/layers/celestial.js Normal file
View File

@ -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' };
}

View File

@ -181,6 +181,7 @@ const LAYER_MODULES = [
'./layers/aircraft.js',
'./layers/military.js',
'./layers/radius.js',
'./layers/celestial.js',
];
const activeLayers = [];

View File

@ -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',