Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822c69944c | ||
|
|
f2608f1172 |
File diff suppressed because it is too large
Load Diff
@@ -172,25 +172,6 @@ describe('StarFieldRenderer', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a star just outside the frame, however close the pointer gets to the edge', () => {
|
|
||||||
// Its hit area is the drawn size plus a slop, so near an edge that area reaches past the
|
|
||||||
// frame — and a system nobody can see is not one a click should fly into.
|
|
||||||
const offScreen = [star({ id: 9, x: 0, y: 0, z: -10, magnitude: -2 })];
|
|
||||||
const renderer = new StarFieldRenderer(offScreen, packPositions(offScreen));
|
|
||||||
const centre = new THREE.Vector3(0, 0, -10).project(camera);
|
|
||||||
expect(renderer.pickAt(new THREE.Vector2(centre.x, centre.y), camera, camera.aspect)).toBe(9);
|
|
||||||
|
|
||||||
// The same star, just outside the top of the frame: its centre at NDC 1.01, its disc ending at
|
|
||||||
// 1.0033. A click at 0.995 is within its hit radius (0.0167) — so without the frame test this
|
|
||||||
// picks it — while none of the star is on screen.
|
|
||||||
const above = [star({ id: 9, x: 0, y: 10 * Math.tan((camera.fov * Math.PI) / 360) * 1.01, z: -10, magnitude: -2 })];
|
|
||||||
const outside = new StarFieldRenderer(above, packPositions(above));
|
|
||||||
|
|
||||||
expect(outside.pickAt(new THREE.Vector2(0, 0.995), camera, camera.aspect)).toBeUndefined();
|
|
||||||
renderer.dispose();
|
|
||||||
outside.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picks the star nearest the pointer when several are in view', () => {
|
it('picks the star nearest the pointer when several are in view', () => {
|
||||||
const spread = [
|
const spread = [
|
||||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||||
|
|||||||
@@ -406,11 +406,6 @@ export class StarFieldRenderer {
|
|||||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||||
* sub-pixel at the far end of the camera's range.
|
* sub-pixel at the far end of the camera's range.
|
||||||
*
|
|
||||||
* Only stars on screen can be picked. The hit area is the drawn size plus a slop of
|
|
||||||
* {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the
|
|
||||||
* last few pixels of the view used to be able to fly into a system whose star was outside it,
|
|
||||||
* with nothing on screen to explain where it had gone.
|
|
||||||
*/
|
*/
|
||||||
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||||
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
||||||
@@ -434,16 +429,10 @@ export class StarFieldRenderer {
|
|||||||
if (projected.z < -1 || projected.z > 1) {
|
if (projected.z < -1 || projected.z > 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||||
const drawnRadius = (0.5 * sizes[index]) / tanHalfFov;
|
const ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||||
// Off screen if no part of the drawn disc is inside the frame. Tested before the slop is
|
|
||||||
// added: the slop is forgiveness for an imprecise click on a star you can see, not a reach
|
|
||||||
// past the edge to one you cannot.
|
|
||||||
if (Math.abs(projected.x) - drawnRadius / aspect > 1 || Math.abs(projected.y) - drawnRadius > 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ndcRadius = drawnRadius + PICK_NDC_SLOP;
|
|
||||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||||
const dy = projected.y - pointerNdc.y;
|
const dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co
|
|||||||
import {
|
import {
|
||||||
bodyMarkerRadiusAu,
|
bodyMarkerRadiusAu,
|
||||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||||
|
starGlowExtentAu,
|
||||||
starMarkerRadiusAu,
|
starMarkerRadiusAu,
|
||||||
systemFrameRadiusAu,
|
systemFrameRadiusAu,
|
||||||
systemFramingDistanceAu,
|
systemFramingDistanceAu,
|
||||||
@@ -112,6 +113,105 @@ describe('systemFramingDistanceAu', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('starGlowExtentAu', () => {
|
||||||
|
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
|
||||||
|
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
|
||||||
|
|
||||||
|
/** The halo's visual radius, in AU, at the distance this system is framed from. */
|
||||||
|
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||||
|
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
||||||
|
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameRadiusFor(outermostAu: number): number {
|
||||||
|
const rings = systemGridRingsAu(outermostAu);
|
||||||
|
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
||||||
|
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||||
|
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apparentPixels(innermostAu: number, outermostAu: number): number {
|
||||||
|
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('scales with the star for a compact system, where the star is already big enough', () => {
|
||||||
|
// A tight frame relative to the star, so the star's own multiple is what decides.
|
||||||
|
const marker = 0.02;
|
||||||
|
const tightFrame = 0.5;
|
||||||
|
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
||||||
|
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors against the frame once the star would otherwise vanish into it', () => {
|
||||||
|
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
||||||
|
// the multiple of the star is nothing, so the frame decides instead.
|
||||||
|
const tinyStar = 0.001;
|
||||||
|
const wideFrame = 56;
|
||||||
|
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
||||||
|
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
||||||
|
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
||||||
|
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the inner orbits clear of the halo', () => {
|
||||||
|
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
|
||||||
|
// star, which bounds the halo from above just as visibility bounds it from below.
|
||||||
|
const halo = haloRadiusAu(0.387, 39.288);
|
||||||
|
const VENUS_AU = 0.723;
|
||||||
|
const EARTH_AU = 1;
|
||||||
|
expect(halo).toBeLessThan(VENUS_AU);
|
||||||
|
expect(halo).toBeLessThan(EARTH_AU);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot clear Mercury as well, and does not pretend to', () => {
|
||||||
|
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
|
||||||
|
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
|
||||||
|
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the floor across every system scale the datasets contain', () => {
|
||||||
|
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
||||||
|
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
||||||
|
for (const [innermost, outermost] of [
|
||||||
|
[0.387, 39.288],
|
||||||
|
[0.035, 0.204],
|
||||||
|
[0.01154, 0.06189],
|
||||||
|
[1.2, 12.4]
|
||||||
|
]) {
|
||||||
|
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not blot out the system it sits in', () => {
|
||||||
|
for (const [innermost, outermost] of [
|
||||||
|
[0.387, 39.288],
|
||||||
|
[0.035, 0.204],
|
||||||
|
[0.01154, 0.06189]
|
||||||
|
]) {
|
||||||
|
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
||||||
|
// Above the floor the multiplier applies...
|
||||||
|
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
||||||
|
// ...and at the floor it cannot dim a star into invisibility.
|
||||||
|
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the star alone when there is no frame to measure against', () => {
|
||||||
|
for (const frame of [0, -1, Number.NaN]) {
|
||||||
|
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the grid and the framing together', () => {
|
describe('the grid and the framing together', () => {
|
||||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
||||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||||
@@ -178,41 +278,54 @@ describe('star and framing together', () => {
|
|||||||
|
|
||||||
describe('bodyMarkerRadiusAu', () => {
|
describe('bodyMarkerRadiusAu', () => {
|
||||||
const EARTH_RADIUS_KM = 6371;
|
const EARTH_RADIUS_KM = 6371;
|
||||||
const KM_PER_AU = 149597870.7;
|
const SOLAR_SPAN_AU = 30.07;
|
||||||
|
|
||||||
it('draws a body at its true size', () => {
|
it('scales in proportion to the system span', () => {
|
||||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
||||||
expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
|
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
||||||
|
|
||||||
|
expect(compact / wide).toBeCloseTo(0.01, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
|
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
||||||
// Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
|
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
||||||
// Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
|
// planet swallowed the whole view.
|
||||||
const jupiter = bodyMarkerRadiusAu(69911);
|
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
||||||
const ganymede = bodyMarkerRadiusAu(2634);
|
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
||||||
const callisto = bodyMarkerRadiusAu(2410);
|
}
|
||||||
const GANYMEDE_SEMI_MAJOR_AXIS_AU = 0.007155;
|
|
||||||
|
|
||||||
expect(ganymede).toBeLessThan(jupiter);
|
|
||||||
expect(callisto).toBeLessThan(ganymede);
|
|
||||||
expect(jupiter + ganymede).toBeLessThan(GANYMEDE_SEMI_MAJOR_AXIS_AU);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
|
it('gives compact and wide systems the same apparent marker size', () => {
|
||||||
const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268;
|
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
||||||
|
|
||||||
expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU);
|
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still renders a bigger body as a bigger marker', () => {
|
it('still renders a bigger body as a bigger marker', () => {
|
||||||
expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
|
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
||||||
|
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||||
|
|
||||||
|
expect(jupiter).toBeGreaterThan(pluto);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to an Earth for a body with no published radius', () => {
|
it('falls back to the smallest marker for a body with no known radius', () => {
|
||||||
for (const nothing of [undefined, 0, -1]) {
|
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
||||||
expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
|
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||||
|
|
||||||
|
expect(unknown).toBeGreaterThan(0);
|
||||||
|
expect(unknown).toBeLessThanOrEqual(pluto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
||||||
|
for (const span of [0, -5, Number.NaN]) {
|
||||||
|
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves the solar system essentially as it was before scaling', () => {
|
||||||
|
// The constants were tuned at this span, so the scale factor here is ~1.
|
||||||
|
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('systemGridRingsAu', () => {
|
describe('systemGridRingsAu', () => {
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
|||||||
*/
|
*/
|
||||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
|
||||||
|
* fraction of the framed radius.
|
||||||
|
*
|
||||||
|
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
|
||||||
|
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
|
||||||
|
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
|
||||||
|
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
|
||||||
|
* across; there is no radius that satisfies both, because the information genuinely does not
|
||||||
|
* fit on one screen at that zoom.
|
||||||
|
*
|
||||||
|
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
|
||||||
|
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
|
||||||
|
* honest to the orbits and the halo is floored against the frame.
|
||||||
|
*
|
||||||
|
* The floor is set by what it must not cover. Its visual radius is half the extent, so a floor
|
||||||
|
* of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave
|
||||||
|
* legible sit at their own fraction of that same radius. In the solar system, framed to hold
|
||||||
|
* Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2%
|
||||||
|
* leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any
|
||||||
|
* halo large enough to see, since the orbit itself is only a few pixels wide there.
|
||||||
|
*/
|
||||||
|
const STAR_GLOW_TO_MARKER = 3.2;
|
||||||
|
const MIN_STAR_GLOW_TO_FRAME = 0.02;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||||
@@ -125,6 +150,20 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
|||||||
return distanceAu * tightHalfExtent(viewport);
|
return distanceAu * tightHalfExtent(viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
||||||
|
*
|
||||||
|
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
||||||
|
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
||||||
|
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
||||||
|
* caller dim the halo for stars drawn without a real photograph.
|
||||||
|
*/
|
||||||
|
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
||||||
|
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
||||||
|
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
||||||
|
return Math.max(fromStar, fromFrame);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||||
* around it.
|
* around it.
|
||||||
@@ -184,35 +223,32 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
|
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
|
||||||
* a radius nor a mass to estimate one from is drawn as: an Earth, for want of anything better —
|
* scaled against. The marker constants below were tuned by eye at this scale.
|
||||||
* exoplanets with a mass and no radius get an estimate from their mass before they reach here.
|
|
||||||
*/
|
*/
|
||||||
const KM_PER_AU = 149597870.7;
|
const REFERENCE_SYSTEM_SPAN_AU = 30;
|
||||||
const DEFAULT_BODY_RADIUS_KM = 6371;
|
|
||||||
|
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
|
||||||
|
const MIN_MARKER_RADIUS_AU = 0.012;
|
||||||
|
const MAX_MARKER_RADIUS_AU = 0.09;
|
||||||
|
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
|
||||||
|
const MARKER_RADIUS_KM_PER_AU = 18000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Sun's own radius, in AU — the one star whose size this map knows.
|
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
||||||
*
|
*
|
||||||
* Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
|
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
|
||||||
* orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
|
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
|
||||||
* positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
|
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
|
||||||
* here, and until the ETL fetches it, a system's star is the one body in the view that is not
|
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
|
||||||
* to scale.
|
* a single planet swallows the entire view.
|
||||||
|
*
|
||||||
|
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
|
||||||
|
* planets as small dots on them.
|
||||||
*/
|
*/
|
||||||
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
|
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
|
||||||
|
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
||||||
|
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
||||||
|
|
||||||
/**
|
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
|
||||||
* Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated.
|
|
||||||
*
|
|
||||||
* Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size
|
|
||||||
* of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so
|
|
||||||
* every moon orbited inside its parent. True scale needs no rule to prevent that: physics already
|
|
||||||
* puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit.
|
|
||||||
*
|
|
||||||
* What true scale costs is visibility at the framing that holds a whole system, where every body
|
|
||||||
* is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`.
|
|
||||||
*/
|
|
||||||
export function bodyMarkerRadiusAu(radiusKm: number | undefined): number {
|
|
||||||
return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,95 +372,3 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rotation', () => {
|
|
||||||
/** Earth, near enough: a day of 23.934 h, tipped 23.44 degrees off its orbit. */
|
|
||||||
function spinning(overrides: Partial<BodyRecord> = {}): BodyRecord {
|
|
||||||
return {
|
|
||||||
id: 'earth',
|
|
||||||
systemStarId: 0,
|
|
||||||
name: 'Earth',
|
|
||||||
kind: 'planet',
|
|
||||||
radiusKm: 6371,
|
|
||||||
orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
|
|
||||||
rotationPeriodHours: 23.934,
|
|
||||||
obliquityDeg: 23.4392911,
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far the marker has turned about its own axis between two dates, in degrees. */
|
|
||||||
function turnedDegrees(body: BodyRecord, afterDays: number): number {
|
|
||||||
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD);
|
|
||||||
const start = renderer.members[0].marker.quaternion.clone();
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD + afterDays);
|
|
||||||
const turn = start.invert().multiply(renderer.members[0].marker.quaternion);
|
|
||||||
const axis = new THREE.Vector3();
|
|
||||||
const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
|
|
||||||
turn.normalize();
|
|
||||||
axis.set(turn.x, turn.y, turn.z);
|
|
||||||
const signed = axis.y >= 0 ? angle : -angle;
|
|
||||||
return (signed * 180) / Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('turns a body once per its own sidereal day', () => {
|
|
||||||
// A full turn in 23.934 h, so a quarter of that is a quarter turn.
|
|
||||||
expect(Math.abs(turnedDegrees(spinning(), 23.934 / 96))).toBeCloseTo(90, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which way a body spins in the world: its angular velocity projected on its orbit's normal.
|
|
||||||
* Positive is prograde, turning the same way it goes round; negative is retrograde.
|
|
||||||
*/
|
|
||||||
function spinSense(body: BodyRecord): number {
|
|
||||||
const renderer = new SystemOrbitsRenderer([body], [], undefined, 1);
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD);
|
|
||||||
const start = renderer.members[0].marker.quaternion.clone();
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD + 0.01);
|
|
||||||
const turn = renderer.members[0].marker.quaternion.clone().multiply(start.invert());
|
|
||||||
const axis = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w));
|
|
||||||
return axis.normalize().dot(new THREE.Vector3(0, 0, 1).applyQuaternion(renderer.referenceFrame));
|
|
||||||
}
|
|
||||||
|
|
||||||
it('turns Venus backwards, as Horizons gives it: a negative rate and an obliquity past 90', () => {
|
|
||||||
// Both say retrograde, in two conventions. Applied together they cancelled into a forward
|
|
||||||
// turn, which is how Venus and Uranus used to be drawn.
|
|
||||||
const venus = spinning({ id: 'venus', rotationPeriodHours: -5832.54, obliquityDeg: 177.3 });
|
|
||||||
|
|
||||||
expect(spinSense(spinning())).toBeGreaterThan(0.9);
|
|
||||||
expect(spinSense(venus)).toBeLessThan(-0.9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads the sign of the period only where no obliquity says which way the pole points', () => {
|
|
||||||
expect(spinSense(spinning({ rotationPeriodHours: -23.934, obliquityDeg: undefined }))).toBeLessThan(-0.9);
|
|
||||||
expect(spinSense(spinning({ rotationPeriodHours: 23.934, obliquityDeg: undefined }))).toBeGreaterThan(0.9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a body with no published rotation still', () => {
|
|
||||||
// Titan: Horizons states no period for it, and an invented one would be a claim.
|
|
||||||
const renderer = new SystemOrbitsRenderer([spinning({ rotationPeriodHours: undefined })], [], undefined, 1);
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD);
|
|
||||||
const start = renderer.members[0].marker.quaternion.clone();
|
|
||||||
renderer.update(DEFAULT_EPOCH_JD + 40);
|
|
||||||
|
|
||||||
expect(renderer.members[0].marker.quaternion.angleTo(start)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('exoplanet size without a measured radius', () => {
|
|
||||||
const radiusOf = (overrides: Partial<ExoplanetRecord>): number => {
|
|
||||||
const renderer = new SystemOrbitsRenderer([], [exoplanet(overrides)], undefined, 1);
|
|
||||||
return ((renderer.members[0].marker as THREE.Mesh).geometry as THREE.SphereGeometry).parameters.radius;
|
|
||||||
};
|
|
||||||
const EARTH_AU = 6371 / 149597870.7;
|
|
||||||
|
|
||||||
it('draws a giant known only by its mass at about Jupiter’s size, not at an Earth', () => {
|
|
||||||
// 14 Her b: 2 829 Earth masses, no radius. It used to come out the size of the Earth.
|
|
||||||
expect(radiusOf({ radiusEarth: undefined, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(11.2, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a measured radius over any estimate', () => {
|
|
||||||
expect(radiusOf({ radiusEarth: 1.88, massEarth: 2829 }) / EARTH_AU).toBeCloseTo(1.88, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import * as THREE from 'three/webgpu';
|
|||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
import { gmForParent } from '../../shared/astro/constants';
|
import { gmForParent } from '../../shared/astro/constants';
|
||||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
|
|
||||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
@@ -19,8 +18,6 @@ export interface SystemMember {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
marker: THREE.Object3D;
|
marker: THREE.Object3D;
|
||||||
/** For a moon, the id of the body it orbits: what its drawn size is held against. */
|
|
||||||
parentId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
|
||||||
@@ -122,110 +119,21 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
|
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
||||||
* with a texture derived from its measurements where none has — and lit by its star either way,
|
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
||||||
* so a world shows the day and night it actually has.
|
* a consequence of its measurements rather than of which list it came from.
|
||||||
*
|
*
|
||||||
* The photographs were already in the repository, used only by the detail page: the system view
|
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
||||||
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
|
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
||||||
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
|
|
||||||
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
|
|
||||||
*/
|
*/
|
||||||
function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
|
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
||||||
const photograph = id ? bodyTexturePath(id) : undefined;
|
const material = appearance
|
||||||
// 128 by 64 for the derived texture, not the detail page's 512 by 256: that size costs about
|
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
||||||
// 60 ms a body on the main thread, 360 ms on entering a six-planet system, for a disc that is a
|
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||||
// few pixels across until the camera is on top of it.
|
|
||||||
const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance, { width: 128, height: 64 }) : undefined;
|
|
||||||
const material = new THREE.MeshStandardMaterial({
|
|
||||||
map,
|
|
||||||
color: map ? 0xffffff : colorForKind(kind),
|
|
||||||
roughness: 1,
|
|
||||||
metalness: 0
|
|
||||||
});
|
|
||||||
return new THREE.Mesh(geometry, material);
|
return new THREE.Mesh(geometry, material);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The star's own light, at the centre of the system it lights.
|
|
||||||
*
|
|
||||||
* `decay` is 0, which is not what light does: a point source falls off with the square of the
|
|
||||||
* distance, and under that law Neptune, at 30.2 AU, receives about a six-thousandth of what
|
|
||||||
* Mercury does at 0.39 AU and reads as black. The map is a set of worlds to look at rather than a
|
|
||||||
* light meter, so each is lit as a photograph of it would be — the same concession the pixel
|
|
||||||
* floor makes for size. What the light does carry truthfully is which side is day: every body
|
|
||||||
* shows its lit face toward the star, and the terminator falls where it really falls.
|
|
||||||
*
|
|
||||||
* White, at π: a Lambertian surface returns intensity / π of its texture where the light falls
|
|
||||||
* square on it, so π gives back the photograph itself at the point facing the star, and less
|
|
||||||
* towards the limb. A warm tint or a smaller figure darkened the photographs below what they are.
|
|
||||||
*/
|
|
||||||
function starLight(): THREE.PointLight {
|
|
||||||
const light = new THREE.PointLight(0xffffff, Math.PI, 0, 0);
|
|
||||||
light.position.set(0, 0, 0);
|
|
||||||
return light;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sphere segments. On a UV sphere the silhouette seen down the pole is the ring of width segments
|
|
||||||
* and the one seen from the side is the meridian profile, so height at half the width makes the
|
|
||||||
* error the same from every direction: at 64 by 32 a body filling the screen — Jupiter reaches
|
|
||||||
* 641 px of radius in the plan view — strays under a pixel from its true circle.
|
|
||||||
*/
|
|
||||||
const MARKER_WIDTH_SEGMENTS = 64;
|
|
||||||
const MARKER_HEIGHT_SEGMENTS = 32;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A drawn radius, in Earth radii, for an exoplanet that has a mass and no measured radius — 1 076
|
|
||||||
* of the 1 692 drawn, most of them found by radial velocity, and most of those giants: their
|
|
||||||
* median is 315 Earth masses. Drawn at an Earth, as they were, a nine-Jupiter-mass planet came out
|
|
||||||
* smaller than its system's super-Earth.
|
|
||||||
*
|
|
||||||
* A rough power law, capped at Jupiter's radius: giants from a third of a Jupiter mass to ten are
|
|
||||||
* all about Jupiter's size, since past that point added mass compresses rather than inflates. It
|
|
||||||
* sets a size to draw, not a figure to print — the readout still says the radius is unknown.
|
|
||||||
*/
|
|
||||||
function radiusFromMassEarth(massEarth: number | null | undefined): number | undefined {
|
|
||||||
return massEarth && massEarth > 0 ? Math.min(JUPITER_RADIUS_EARTH, massEarth ** 0.55) : undefined;
|
|
||||||
}
|
|
||||||
const JUPITER_RADIUS_EARTH = 11.2;
|
|
||||||
|
|
||||||
/** Local axis a sphere is built around, and what the spin is applied about. */
|
|
||||||
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
|
|
||||||
const HOURS_PER_DAY = 24;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a body is turned at a given date: its own sidereal rotation, about its own axis.
|
|
||||||
*
|
|
||||||
* The obliquity fixes how far the pole leans from the orbit normal, and nothing more: which way
|
|
||||||
* it leans needs the pole's right ascension, which the Horizons pages this reads do not carry. The
|
|
||||||
* lean is taken about the orbit's ascending node because that is the one line the elements name,
|
|
||||||
* not because the data says so — so the tilt is real and its azimuth is not. Likewise the phase:
|
|
||||||
* each body starts at its elements' epoch (2025-01-01 here) in an arbitrary orientation, the
|
|
||||||
* shortest rotation of +Y onto its axis, and turns from there. The rate and the sense are real;
|
|
||||||
* the face towards the camera is not.
|
|
||||||
*
|
|
||||||
* Horizons states a retrograde spin twice over, in two conventions: an obliquity past 90 degrees
|
|
||||||
* (Venus 177.3, Uranus 97.8) and a negative rate. Either one alone turns the body backwards, and
|
|
||||||
* both together cancel into a forward turn — which is how Venus and Uranus were drawn. Where an
|
|
||||||
* obliquity is given it carries the sense, and the period is taken as a magnitude; the sign of the
|
|
||||||
* period is only read for a body with no obliquity at all.
|
|
||||||
*/
|
|
||||||
function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number | undefined, epochJd: number): THREE.Quaternion {
|
|
||||||
const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
|
||||||
const inclination = elements.inclinationDeg * DEG_TO_RAD;
|
|
||||||
const nodeDirection = new THREE.Vector3(Math.cos(node), Math.sin(node), 0);
|
|
||||||
const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination))
|
|
||||||
.applyAxisAngle(nodeDirection, (obliquityDeg ?? 0) * DEG_TO_RAD)
|
|
||||||
.applyQuaternion(frame);
|
|
||||||
const period = obliquityDeg === undefined ? rotationPeriodHours : Math.abs(rotationPeriodHours);
|
|
||||||
const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / period;
|
|
||||||
return new THREE.Quaternion()
|
|
||||||
.setFromUnitVectors(SPIN_AXIS, axis)
|
|
||||||
.multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackedTopLevelBody {
|
interface TrackedTopLevelBody {
|
||||||
id: string;
|
id: string;
|
||||||
kind: SystemMemberKind;
|
kind: SystemMemberKind;
|
||||||
@@ -236,9 +144,6 @@ interface TrackedTopLevelBody {
|
|||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
/** AU position last computed for this body; moons read their parent's here. */
|
/** AU position last computed for this body; moons read their parent's here. */
|
||||||
position: THREE.Vector3;
|
position: THREE.Vector3;
|
||||||
/** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */
|
|
||||||
rotationPeriodHours?: number;
|
|
||||||
obliquityDeg?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedMoon {
|
interface TrackedMoon {
|
||||||
@@ -249,8 +154,6 @@ interface TrackedMoon {
|
|||||||
frame: THREE.Quaternion;
|
frame: THREE.Quaternion;
|
||||||
pivot: THREE.Group;
|
pivot: THREE.Group;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
rotationPeriodHours?: number;
|
|
||||||
obliquityDeg?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,6 +206,8 @@ export class SystemOrbitsRenderer {
|
|||||||
const members: SystemMember[] = [];
|
const members: SystemMember[] = [];
|
||||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||||
|
|
||||||
|
// Measured before anything is built, because marker sizes are scaled against the span and
|
||||||
|
// the markers are created as the bodies are added.
|
||||||
const topLevelAxes = [
|
const topLevelAxes = [
|
||||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
||||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
||||||
@@ -322,7 +227,7 @@ export class SystemOrbitsRenderer {
|
|||||||
}
|
}
|
||||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||||
const kind: SystemMemberKind = body.kind;
|
const kind: SystemMemberKind = body.kind;
|
||||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
|
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||||
members.push({ id: body.id, kind, marker: tracked.marker });
|
members.push({ id: body.id, kind, marker: tracked.marker });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,8 +240,8 @@ export class SystemOrbitsRenderer {
|
|||||||
if (!parentTracked) {
|
if (!parentTracked) {
|
||||||
continue; // orphaned moon reference; skip rather than crash.
|
continue; // orphaned moon reference; skip rather than crash.
|
||||||
}
|
}
|
||||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
|
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id });
|
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||||
@@ -351,8 +256,7 @@ export class SystemOrbitsRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||||
const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth);
|
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||||
const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined;
|
|
||||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
||||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||||
const gm = resolveGravitationalParameter({
|
const gm = resolveGravitationalParameter({
|
||||||
@@ -395,9 +299,6 @@ export class SystemOrbitsRenderer {
|
|||||||
|
|
||||||
this.object.add(this.grid.object, this.tethers.object);
|
this.object.add(this.grid.object, this.tethers.object);
|
||||||
}
|
}
|
||||||
// The star lights its own system. The star marker itself is unlit — it is the source, not a
|
|
||||||
// surface — so nothing here changes how it is drawn.
|
|
||||||
this.object.add(starLight());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
||||||
@@ -406,9 +307,6 @@ export class SystemOrbitsRenderer {
|
|||||||
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||||
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
||||||
body.marker.position.copy(body.position);
|
body.marker.position.copy(body.position);
|
||||||
if (body.rotationPeriodHours) {
|
|
||||||
body.marker.quaternion.copy(spinFor(body.elements, body.frame, body.rotationPeriodHours, body.obliquityDeg, epochJd));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const moon of this.moons) {
|
for (const moon of this.moons) {
|
||||||
@@ -419,9 +317,6 @@ export class SystemOrbitsRenderer {
|
|||||||
moon.pivot.position.copy(parent.position);
|
moon.pivot.position.copy(parent.position);
|
||||||
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||||
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
||||||
if (moon.rotationPeriodHours) {
|
|
||||||
moon.marker.quaternion.copy(spinFor(moon.elements, moon.frame, moon.rotationPeriodHours, moon.obliquityDeg, epochJd));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moons are left out: their tether would land within a marker's width of their planet's and
|
// Moons are left out: their tether would land within a marker's width of their planet's and
|
||||||
@@ -476,16 +371,15 @@ export class SystemOrbitsRenderer {
|
|||||||
gmAu3PerDay2: number,
|
gmAu3PerDay2: number,
|
||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance,
|
appearance?: PlanetAppearance
|
||||||
rotation?: { periodHours?: number; obliquityDeg?: number }
|
|
||||||
): TrackedTopLevelBody {
|
): TrackedTopLevelBody {
|
||||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||||
const marker = buildMarker(id, kind, radiusKm, appearance);
|
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||||
this.object.add(orbitLine, marker);
|
this.object.add(orbitLine, marker);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
|
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
|
||||||
this.topLevelBodies.push(tracked);
|
this.topLevelBodies.push(tracked);
|
||||||
return tracked;
|
return tracked;
|
||||||
}
|
}
|
||||||
@@ -497,18 +391,17 @@ export class SystemOrbitsRenderer {
|
|||||||
radiusKm: number | undefined,
|
radiusKm: number | undefined,
|
||||||
parent: TrackedTopLevelBody,
|
parent: TrackedTopLevelBody,
|
||||||
frame: THREE.Quaternion,
|
frame: THREE.Quaternion,
|
||||||
appearance?: PlanetAppearance,
|
appearance?: PlanetAppearance
|
||||||
rotation?: { periodHours?: number; obliquityDeg?: number }
|
|
||||||
): TrackedMoon {
|
): TrackedMoon {
|
||||||
const pivot = new THREE.Group();
|
const pivot = new THREE.Group();
|
||||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||||
const marker = buildMarker(id, 'moon', radiusKm, appearance);
|
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||||
pivot.add(orbitLine, marker);
|
pivot.add(orbitLine, marker);
|
||||||
this.object.add(pivot);
|
this.object.add(pivot);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
|
|
||||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
|
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
|
||||||
this.moons.push(moon);
|
this.moons.push(moon);
|
||||||
return moon;
|
return moon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
import {
|
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
ElementRef,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
OnInit,
|
|
||||||
output,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
|
|
||||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
import { TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
|
||||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
import { SearchComponent } from '../search/search.component';
|
import { SearchComponent } from '../search/search.component';
|
||||||
import {
|
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
|
||||||
RouteRequest,
|
|
||||||
RouteResult,
|
|
||||||
RoutesPanelComponent,
|
|
||||||
RouteStarOption,
|
|
||||||
} from './routes-panel.component';
|
|
||||||
|
|
||||||
export interface HudReadout {
|
export interface HudReadout {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -49,16 +31,7 @@ export interface HudDisplay {
|
|||||||
readonly plan: boolean;
|
readonly plan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = {
|
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
|
||||||
labels: true,
|
|
||||||
orbits: true,
|
|
||||||
grid: true,
|
|
||||||
deepSky: true,
|
|
||||||
sky: true,
|
|
||||||
systems: true,
|
|
||||||
jumpLinks: false,
|
|
||||||
plan: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
{ key: 'labels', label: 'Labels' },
|
{ key: 'labels', label: 'Labels' },
|
||||||
@@ -68,26 +41,17 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
|||||||
{ key: 'sky', label: 'Sky' },
|
{ key: 'sky', label: 'Sky' },
|
||||||
{ key: 'systems', label: 'Systems' },
|
{ key: 'systems', label: 'Systems' },
|
||||||
{ key: 'jumpLinks', label: 'Jump links' },
|
{ key: 'jumpLinks', label: 'Jump links' },
|
||||||
{ key: 'plan', label: 'Plan view' },
|
{ key: 'plan', label: 'Plan view' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||||
|
|
||||||
const TAB_LABELS: Record<DockTab, string> = {
|
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
||||||
search: 'Search',
|
|
||||||
readout: 'Readout',
|
|
||||||
routes: 'Routes',
|
|
||||||
bookmarks: 'Bookmarks',
|
|
||||||
display: 'Display',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||||
const wideViewportQuery =
|
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
|
||||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
||||||
? window.matchMedia(WIDE_VIEWPORT)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function isWideViewport(): boolean {
|
function isWideViewport(): boolean {
|
||||||
return wideViewportQuery?.matches ?? true;
|
return wideViewportQuery?.matches ?? true;
|
||||||
@@ -110,10 +74,7 @@ function isWideViewport(): boolean {
|
|||||||
selector: 'app-hud-dock',
|
selector: 'app-hud-dock',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||||
host: {
|
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
||||||
class:
|
|
||||||
'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6',
|
|
||||||
},
|
|
||||||
template: `
|
template: `
|
||||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||||
@@ -124,55 +85,29 @@ function isWideViewport(): boolean {
|
|||||||
locking on, once per switch, never per keystroke. -->
|
locking on, once per switch, never per keystroke. -->
|
||||||
@switch (tab) {
|
@switch (tab) {
|
||||||
@case ('search') {
|
@case ('search') {
|
||||||
<section
|
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
|
||||||
id="dock-panel-search"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="dock-tab-search"
|
|
||||||
class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"
|
|
||||||
>
|
|
||||||
<app-search (picked)="onPicked()" />
|
<app-search (picked)="onPicked()" />
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('readout') {
|
@case ('readout') {
|
||||||
<section
|
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||||
id="dock-panel-readout"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="dock-tab-readout"
|
|
||||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
|
||||||
>
|
|
||||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||||
<div class="mt-1 flex items-start gap-2">
|
<div class="mt-1 flex items-start gap-2">
|
||||||
<p
|
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||||
data-testid="hud-title"
|
|
||||||
class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase"
|
|
||||||
>
|
|
||||||
{{ title() }}
|
|
||||||
</p>
|
|
||||||
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||||||
truthiness test is what would quietly make the Solar System the one
|
truthiness test is what would quietly make the Solar System the one
|
||||||
system nobody could keep. -->
|
system nobody could keep. -->
|
||||||
@if (keepableStarId() !== null) {
|
@if (keepableStarId() !== null) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
|
||||||
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||||
"
|
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
|
||||||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
(click)="
|
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||||
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
|
>
|
||||||
"
|
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
|
||||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
</button>
|
||||||
[class]="
|
|
||||||
bookmarks.has('star', keepableStarId()!)
|
|
||||||
? 'text-accent'
|
|
||||||
: 'text-muted hover:text-accent'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<app-bookmark-icon
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
[kept]="bookmarks.has('star', keepableStarId()!)"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (subtitle()) {
|
@if (subtitle()) {
|
||||||
@@ -182,54 +117,30 @@ function isWideViewport(): boolean {
|
|||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
@for (readout of readouts(); track readout.label) {
|
@for (readout of readouts(); track readout.label) {
|
||||||
<div>
|
<div>
|
||||||
<dt class="type-label text-muted">
|
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
||||||
{{ readout.label }}
|
|
||||||
@if (readout.derived) {
|
|
||||||
<span class="text-accent/80" aria-hidden="true">*</span>
|
|
||||||
}
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
@if (note() || hasDerived()) {
|
@if (note() || hasDerived()) {
|
||||||
<p
|
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
||||||
class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted"
|
|
||||||
>
|
|
||||||
@if (hasDerived()) {
|
|
||||||
<span class="text-accent/80">*</span> Derived, not catalogued.
|
|
||||||
}
|
|
||||||
{{ note() }}
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('bookmarks') {
|
@case ('bookmarks') {
|
||||||
<section
|
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
|
||||||
id="dock-panel-bookmarks"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="dock-tab-bookmarks"
|
|
||||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg"
|
|
||||||
>
|
|
||||||
@if (bookmarks.bookmarks().length) {
|
@if (bookmarks.bookmarks().length) {
|
||||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||||
@for (
|
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
|
||||||
bookmark of bookmarks.bookmarks();
|
|
||||||
track bookmark.kind + ':' + bookmark.id
|
|
||||||
) {
|
|
||||||
<li class="flex items-stretch">
|
<li class="flex items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="onBookmarkChosen(bookmark)"
|
(click)="onBookmarkChosen(bookmark)"
|
||||||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
|
||||||
bookmark.name
|
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
||||||
}}</span>
|
|
||||||
<span class="type-label shrink-0 text-muted">{{
|
|
||||||
bookmark.kind === 'star' ? 'System' : 'Body'
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -237,15 +148,7 @@ function isWideViewport(): boolean {
|
|||||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
||||||
class="h-3 w-3"
|
|
||||||
viewBox="0 0 14 14"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M3 3l8 8M11 3l-8 8" />
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -254,20 +157,13 @@ function isWideViewport(): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="px-3 py-3 text-sm text-muted">
|
<p class="px-3 py-3 text-sm text-muted">
|
||||||
Nothing kept yet. The
|
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
|
||||||
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
|
||||||
readout or a body keeps it here, in this browser.
|
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('display') {
|
@case ('display') {
|
||||||
<section
|
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||||
id="dock-panel-display"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="dock-tab-display"
|
|
||||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
|
||||||
>
|
|
||||||
<p class="type-label text-muted">Layers</p>
|
<p class="type-label text-muted">Layers</p>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@for (layer of layers; track layer.key) {
|
@for (layer of layers; track layer.key) {
|
||||||
@@ -276,63 +172,14 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-pressed]="isOn(layer.key)"
|
[attr.aria-pressed]="isOn(layer.key)"
|
||||||
(click)="toggleLayer(layer.key)"
|
(click)="toggleLayer(layer.key)"
|
||||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
[class]="
|
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
||||||
isOn(layer.key)
|
|
||||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
|
||||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||||
<span
|
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
|
||||||
aria-hidden="true"
|
|
||||||
class="h-1.5 w-1.5 border border-current"
|
|
||||||
[class.bg-current]="isOn(layer.key)"
|
|
||||||
></span>
|
|
||||||
{{ layer.label }}
|
{{ layer.label }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
|
||||||
difference between a still picture and an orrery. -->
|
|
||||||
<p class="type-label mt-4 text-muted">Clock</p>
|
|
||||||
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
|
||||||
carries that to a screen reader and to the arrow keys without any script. -->
|
|
||||||
<div
|
|
||||||
class="mt-2 flex flex-wrap items-center gap-2"
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Clock rate"
|
|
||||||
>
|
|
||||||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
|
||||||
<label
|
|
||||||
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
|
||||||
[class]="
|
|
||||||
time.rate() === rate.secondsPerSecond
|
|
||||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
|
||||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="clock-rate"
|
|
||||||
class="sr-only"
|
|
||||||
[value]="rate.secondsPerSecond"
|
|
||||||
[checked]="time.rate() === rate.secondsPerSecond"
|
|
||||||
(change)="time.setRate(rate.secondsPerSecond)"
|
|
||||||
/>
|
|
||||||
{{ rate.label }}
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
@if (!time.atNow()) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="time.reset()"
|
|
||||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
|
||||||
>
|
|
||||||
Back to now
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,13 +190,7 @@ function isWideViewport(): boolean {
|
|||||||
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
||||||
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||||||
@if (routing()) {
|
@if (routing()) {
|
||||||
<section
|
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" [hidden]="activeTab() !== 'routes'" class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
||||||
id="dock-panel-routes"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="dock-tab-routes"
|
|
||||||
[hidden]="activeTab() !== 'routes'"
|
|
||||||
class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"
|
|
||||||
>
|
|
||||||
<app-routes-panel
|
<app-routes-panel
|
||||||
[result]="routeResult()"
|
[result]="routeResult()"
|
||||||
[pending]="routePending()"
|
[pending]="routePending()"
|
||||||
@@ -374,39 +215,21 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||||
(click)="toggleTab(tab)"
|
(click)="toggleTab(tab)"
|
||||||
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||||
[class]="
|
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
|
||||||
activeTab() === tab
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'text-muted hover:bg-accent/8 hover:text-accent'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ tabLabel(tab) }}
|
{{ tabLabel(tab) }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Only while the map is away from the present: at the present the date is
|
|
||||||
today's, which the reader's own machine already says. -->
|
|
||||||
@if (date()) {
|
|
||||||
<p
|
|
||||||
class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
|
||||||
data-testid="hud-date"
|
|
||||||
>
|
|
||||||
<span class="type-label text-muted">Date</span>
|
|
||||||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
@if (range()) {
|
@if (range()) {
|
||||||
<p
|
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
||||||
class="flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
|
||||||
[class.ml-auto]="!date()"
|
|
||||||
>
|
|
||||||
<span class="type-label text-muted">Range</span>
|
<span class="type-label text-muted">Range</span>
|
||||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`
|
||||||
})
|
})
|
||||||
export class HudDockComponent implements OnInit {
|
export class HudDockComponent implements OnInit {
|
||||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||||
@@ -418,8 +241,6 @@ export class HudDockComponent implements OnInit {
|
|||||||
readonly note = input('');
|
readonly note = input('');
|
||||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
readonly range = input('');
|
readonly range = input('');
|
||||||
/** The date the sky is drawn for; empty while the map is drawn for the present. */
|
|
||||||
readonly date = input('');
|
|
||||||
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
||||||
readonly display = input<HudDisplay | null>(null);
|
readonly display = input<HudDisplay | null>(null);
|
||||||
/** Which panel is open on a wide viewport when the dock mounts. */
|
/** Which panel is open on a wide viewport when the dock mounts. */
|
||||||
@@ -450,14 +271,12 @@ export class HudDockComponent implements OnInit {
|
|||||||
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||||
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
...(this.display() ? (['display'] as const) : []),
|
...(this.display() ? (['display'] as const) : [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly activeTab = signal<DockTab | null>(null);
|
readonly activeTab = signal<DockTab | null>(null);
|
||||||
|
|
||||||
readonly bookmarks = inject(BookmarksStore);
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
readonly time = inject(TimeStore);
|
|
||||||
readonly timeRates = TIME_RATES;
|
|
||||||
|
|
||||||
private readonly search = viewChild(SearchComponent);
|
private readonly search = viewChild(SearchComponent);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
@@ -512,10 +331,7 @@ export class HudDockComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
target &&
|
|
||||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -527,11 +343,7 @@ export class HudDockComponent implements OnInit {
|
|||||||
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||||
@HostListener('document:pointerdown', ['$event'])
|
@HostListener('document:pointerdown', ['$event'])
|
||||||
onDocumentPointerDown(event: PointerEvent): void {
|
onDocumentPointerDown(event: PointerEvent): void {
|
||||||
if (
|
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
|
||||||
this.activeTab() &&
|
|
||||||
!isWideViewport() &&
|
|
||||||
!this.host.nativeElement.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
this.activeTab.set(null);
|
this.activeTab.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,4 @@ export interface BodyRecord {
|
|||||||
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
|
||||||
*/
|
*/
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
/**
|
|
||||||
* How the body turns on its own axis: the sidereal rotation period in hours, negative where
|
|
||||||
* Horizons gives a negative rate (Venus, Uranus), and the tilt of that axis from its orbital
|
|
||||||
* plane — which past 90 degrees already says the turn is retrograde.
|
|
||||||
*
|
|
||||||
* Absent where Horizons publishes neither — the view then leaves the body still rather than
|
|
||||||
* spinning it at an invented rate.
|
|
||||||
*/
|
|
||||||
rotationPeriodHours?: number;
|
|
||||||
obliquityDeg?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,18 @@ import * as THREE from 'three/webgpu';
|
|||||||
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
||||||
* keyed by the same ids used in `bodies.json`.
|
* keyed by the same ids used in `bodies.json`.
|
||||||
*
|
*
|
||||||
* Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
|
* This map is the whole of what has actually been photographed. Everything else — every
|
||||||
* sphere. Everything else — every exoplanet, since not one has ever been imaged, and every moon
|
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map
|
||||||
* or dwarf planet with no such map in the repository — falls through to
|
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's
|
||||||
* `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
|
* own measured size, mass, orbit and host star instead.
|
||||||
* mass, orbit and host star instead.
|
|
||||||
*
|
*
|
||||||
* Io, Pluto, Titan and Deimos used to be listed with the square photographs of them in
|
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
|
||||||
* `assets/textures/bodies/`: pictures of a lit disc against black sky, not maps. Wrapped round a
|
* Commons — see each file's Commons page for the original credit line):
|
||||||
* sphere they put black sky on a fifth to a third of the surface, in a band up to 57 degrees wide
|
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System
|
||||||
* across the equator that the spin then swept past the camera. They are left out until a real map
|
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto —
|
||||||
* of each is added.
|
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona
|
||||||
*
|
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL
|
||||||
* Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
|
* Cassini true-color view.
|
||||||
* for the original credit line): mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/
|
|
||||||
* saturn-ring/skybox — Solar System Scope texture pack; jupiter — Solar System Scope 8k pack.
|
|
||||||
*/
|
*/
|
||||||
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
const BODY_TEXTURE_PATHS: Record<string, string> = {
|
||||||
mercury: 'assets/textures/bodies/mercury.jpg',
|
mercury: 'assets/textures/bodies/mercury.jpg',
|
||||||
@@ -29,7 +26,11 @@ const BODY_TEXTURE_PATHS: Record<string, string> = {
|
|||||||
saturn: 'assets/textures/bodies/saturn.jpg',
|
saturn: 'assets/textures/bodies/saturn.jpg',
|
||||||
uranus: 'assets/textures/bodies/uranus.jpg',
|
uranus: 'assets/textures/bodies/uranus.jpg',
|
||||||
neptune: 'assets/textures/bodies/neptune.jpg',
|
neptune: 'assets/textures/bodies/neptune.jpg',
|
||||||
moon: 'assets/textures/bodies/moon.jpg'
|
pluto: 'assets/textures/bodies/pluto.jpg',
|
||||||
|
moon: 'assets/textures/bodies/moon.jpg',
|
||||||
|
deimos: 'assets/textures/bodies/deimos.jpg',
|
||||||
|
io: 'assets/textures/bodies/io.jpg',
|
||||||
|
titan: 'assets/textures/bodies/titan.jpg'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { TimeStore } from './time.store';
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
const START = new Date('2026-09-22T12:00:00Z');
|
|
||||||
|
|
||||||
describe('TimeStore', () => {
|
|
||||||
let time: TimeStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(START);
|
|
||||||
time = new TimeStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the world’s own time until asked otherwise', () => {
|
|
||||||
const opened = time.julianDate();
|
|
||||||
vi.advanceTimersByTime(10_000);
|
|
||||||
|
|
||||||
expect(time.julianDate() - opened).toBeCloseTo(10_000 / MS_PER_DAY, 9);
|
|
||||||
expect(time.date().toISOString()).toBe('2026-09-22T12:00:10.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs the sky faster without moving where it starts from', () => {
|
|
||||||
const opened = time.julianDate();
|
|
||||||
time.setRate(3600);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
// A second of watching is an hour of sky, and the date did not jump when the rate changed.
|
|
||||||
expect(time.julianDate() - opened).toBeCloseTo(1 / 24, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('carries on from where it had got to when the rate changes again', () => {
|
|
||||||
time.setRate(86_400);
|
|
||||||
vi.advanceTimersByTime(2000); // two days of sky
|
|
||||||
const afterTwoDays = time.julianDate();
|
|
||||||
|
|
||||||
time.setRate(1);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
// Slowing down keeps the two days: it does not rewind to the wall clock.
|
|
||||||
expect(time.julianDate() - afterTwoDays).toBeCloseTo(1000 / MS_PER_DAY, 9);
|
|
||||||
expect(time.julianDate() - afterTwoDays).toBeLessThan(1 / 24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('knows the map is away from now even once it is back at real time', () => {
|
|
||||||
expect(time.atNow()).toBe(true);
|
|
||||||
time.setRate(2_629_800);
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
time.setRate(1);
|
|
||||||
|
|
||||||
// Real time, months ahead: the rate says nothing about where the clock stands.
|
|
||||||
expect(time.atNow()).toBe(false);
|
|
||||||
time.reset();
|
|
||||||
expect(time.atNow()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('comes back to now, at real time', () => {
|
|
||||||
time.setRate(2_629_800);
|
|
||||||
vi.advanceTimersByTime(5000); // months away
|
|
||||||
expect(time.date().getUTCFullYear()).toBeGreaterThan(START.getUTCFullYear());
|
|
||||||
|
|
||||||
time.reset();
|
|
||||||
|
|
||||||
expect(time.rate()).toBe(1);
|
|
||||||
// Now, not the moment the store was built: five seconds of wall clock have passed.
|
|
||||||
expect(time.date().toISOString()).toBe(new Date(START.getTime() + 5000).toISOString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
|
||||||
|
|
||||||
import { dateToJulianDate } from '../astro/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How fast the map's clock runs, in seconds of sky per second of wall clock.
|
|
||||||
*
|
|
||||||
* The map is built on propagated orbits and published rotation periods, both of which are
|
|
||||||
* functions of a date — so the only thing standing between it and a working orrery is the number
|
|
||||||
* on this list. At real time nothing appears to move: Earth turns 15 degrees an hour and takes a
|
|
||||||
* year to go round, and a reader watching for a minute sees a still picture.
|
|
||||||
*
|
|
||||||
* An hour a second is the rate at which rotation reads — Jupiter turns once every ten seconds of
|
|
||||||
* watching. A day a second is the rate at which the inner planets read. A month a second carries
|
|
||||||
* the outer ones, at which point the inner four are a blur, which is honest: that is what the
|
|
||||||
* solar system does.
|
|
||||||
*/
|
|
||||||
export const TIME_RATES = [
|
|
||||||
{ label: 'Real time', secondsPerSecond: 1 },
|
|
||||||
{ label: '1 h/s', secondsPerSecond: 3600 },
|
|
||||||
{ label: '1 d/s', secondsPerSecond: 86_400 },
|
|
||||||
{ label: '1 mo/s', secondsPerSecond: 2_629_800 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
const JULIAN_DATE_AT_EPOCH = 2440587.5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date the map is drawn for.
|
|
||||||
*
|
|
||||||
* Read every frame rather than held in a signal: it changes continuously, and a signal that
|
|
||||||
* changed sixty times a second would ask the whole HUD to re-render for a number nothing is
|
|
||||||
* watching. The rate *is* a signal, since a reader sets it and the controls read it back.
|
|
||||||
*
|
|
||||||
* Changing the rate re-anchors instead of rewinding: the date carries on from where it had got
|
|
||||||
* to, so speeding up and slowing down never jumps the sky.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class TimeStore {
|
|
||||||
readonly rate = signal<number>(TIME_RATES[0].secondsPerSecond);
|
|
||||||
/**
|
|
||||||
* Whether the map is drawn for the present. Not the same as a rate of one: after an excursion at
|
|
||||||
* a month a second, real time carries on from months ahead, and the map is still away from now.
|
|
||||||
*/
|
|
||||||
readonly atNow = signal(true);
|
|
||||||
|
|
||||||
private anchorJd = dateToJulianDate();
|
|
||||||
private anchorWallMs = Date.now();
|
|
||||||
|
|
||||||
/** Julian date for this instant, at the rate the reader chose. */
|
|
||||||
julianDate(): number {
|
|
||||||
return this.anchorJd + ((Date.now() - this.anchorWallMs) * this.rate()) / MS_PER_DAY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same instant as a date, for anything that prints it.
|
|
||||||
*
|
|
||||||
* Rounded to the millisecond, which is all a `Date` holds: a Julian date near 2 461 000 has
|
|
||||||
* about a twentieth of a millisecond of resolution left in a double, and `new Date` truncates
|
|
||||||
* what is left rather than rounding it, so ten seconds came back as 9.999.
|
|
||||||
*/
|
|
||||||
date(): Date {
|
|
||||||
return new Date(Math.round((this.julianDate() - JULIAN_DATE_AT_EPOCH) * MS_PER_DAY));
|
|
||||||
}
|
|
||||||
|
|
||||||
setRate(secondsPerSecond: number): void {
|
|
||||||
this.anchorJd = this.julianDate();
|
|
||||||
this.anchorWallMs = Date.now();
|
|
||||||
this.rate.set(secondsPerSecond);
|
|
||||||
if (secondsPerSecond !== 1) {
|
|
||||||
this.atNow.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Back to now, at real time — the state the map opens in. */
|
|
||||||
reset(): void {
|
|
||||||
this.anchorJd = dateToJulianDate();
|
|
||||||
this.anchorWallMs = Date.now();
|
|
||||||
this.rate.set(TIME_RATES[0].secondsPerSecond);
|
|
||||||
this.atNow.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-46
@@ -13,9 +13,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 29.19561108948743,
|
"argumentOfPeriapsisDeg": 29.19561108948743,
|
||||||
"meanAnomalyAtEpochDeg": 103.9465145977117,
|
"meanAnomalyAtEpochDeg": 103.9465145977117,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 1407.512239412851,
|
|
||||||
"obliquityDeg": 2.11
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "venus",
|
"id": "venus",
|
||||||
@@ -31,9 +29,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 55.15075425343929,
|
"argumentOfPeriapsisDeg": 55.15075425343929,
|
||||||
"meanAnomalyAtEpochDeg": 280.0749102629981,
|
"meanAnomalyAtEpochDeg": 280.0749102629981,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": -5832.539941165383,
|
|
||||||
"obliquityDeg": 177.3
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "earth",
|
"id": "earth",
|
||||||
@@ -49,9 +45,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 272.9783142442708,
|
"argumentOfPeriapsisDeg": 272.9783142442708,
|
||||||
"meanAnomalyAtEpochDeg": 357.4122246804211,
|
"meanAnomalyAtEpochDeg": 357.4122246804211,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 23.934472399219285,
|
|
||||||
"obliquityDeg": 23.4392911
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mars",
|
"id": "mars",
|
||||||
@@ -67,9 +61,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 286.7114828288203,
|
"argumentOfPeriapsisDeg": 286.7114828288203,
|
||||||
"meanAnomalyAtEpochDeg": 124.444888195349,
|
"meanAnomalyAtEpochDeg": 124.444888195349,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 24.622955438662025,
|
|
||||||
"obliquityDeg": 25.19
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jupiter",
|
"id": "jupiter",
|
||||||
@@ -85,9 +77,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 273.6090683600047,
|
"argumentOfPeriapsisDeg": 273.6090683600047,
|
||||||
"meanAnomalyAtEpochDeg": 58.98282567286461,
|
"meanAnomalyAtEpochDeg": 58.98282567286461,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 9.925102371306965,
|
|
||||||
"obliquityDeg": 3.13
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "saturn",
|
"id": "saturn",
|
||||||
@@ -103,9 +93,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 337.1663598259115,
|
"argumentOfPeriapsisDeg": 337.1663598259115,
|
||||||
"meanAnomalyAtEpochDeg": 264.9877650588944,
|
"meanAnomalyAtEpochDeg": 264.9877650588944,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 10.656221583138441,
|
|
||||||
"obliquityDeg": 26.73
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uranus",
|
"id": "uranus",
|
||||||
@@ -121,9 +109,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 90.49593456147204,
|
"argumentOfPeriapsisDeg": 90.49593456147204,
|
||||||
"meanAnomalyAtEpochDeg": 255.8822481742851,
|
"meanAnomalyAtEpochDeg": 255.8822481742851,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": -17.24003330792427,
|
|
||||||
"obliquityDeg": 97.77
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "neptune",
|
"id": "neptune",
|
||||||
@@ -139,9 +125,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 268.1146665565159,
|
"argumentOfPeriapsisDeg": 268.1146665565159,
|
||||||
"meanAnomalyAtEpochDeg": 319.6858384317641,
|
"meanAnomalyAtEpochDeg": 319.6858384317641,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 16.110037586020876,
|
|
||||||
"obliquityDeg": 28.32
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "pluto",
|
"id": "pluto",
|
||||||
@@ -157,9 +141,7 @@
|
|||||||
"argumentOfPeriapsisDeg": 113.5754868232679,
|
"argumentOfPeriapsisDeg": 113.5754868232679,
|
||||||
"meanAnomalyAtEpochDeg": 51.93655343727463,
|
"meanAnomalyAtEpochDeg": 51.93655343727463,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
}
|
||||||
"rotationPeriodHours": 153.29335198,
|
|
||||||
"obliquityDeg": 119.6
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "moon",
|
"id": "moon",
|
||||||
@@ -176,9 +158,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 290.7825171697369,
|
"meanAnomalyAtEpochDeg": 290.7825171697369,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "earth",
|
"parentBodyId": "earth"
|
||||||
"rotationPeriodHours": 664.8546956215548,
|
|
||||||
"obliquityDeg": 6.67
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phobos",
|
"id": "phobos",
|
||||||
@@ -195,8 +175,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 342.6005509941174,
|
"meanAnomalyAtEpochDeg": 342.6005509941174,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "mars",
|
"parentBodyId": "mars"
|
||||||
"rotationPeriodHours": 7.660212212136228
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "deimos",
|
"id": "deimos",
|
||||||
@@ -213,8 +192,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 273.8943716897566,
|
"meanAnomalyAtEpochDeg": 273.8943716897566,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "mars",
|
"parentBodyId": "mars"
|
||||||
"rotationPeriodHours": 30.304279685850094
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "io",
|
"id": "io",
|
||||||
@@ -231,8 +209,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 74.88524962049125,
|
"meanAnomalyAtEpochDeg": 74.88524962049125,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter",
|
"parentBodyId": "jupiter"
|
||||||
"rotationPeriodHours": 42.51537583211252
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "europa",
|
"id": "europa",
|
||||||
@@ -249,8 +226,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 40.72575250295771,
|
"meanAnomalyAtEpochDeg": 40.72575250295771,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter",
|
"parentBodyId": "jupiter"
|
||||||
"rotationPeriodHours": 85.27848729079142
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ganymede",
|
"id": "ganymede",
|
||||||
@@ -267,8 +243,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 355.7249344187845,
|
"meanAnomalyAtEpochDeg": 355.7249344187845,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter",
|
"parentBodyId": "jupiter"
|
||||||
"rotationPeriodHours": 171.78271980469088
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "callisto",
|
"id": "callisto",
|
||||||
@@ -285,8 +260,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 126.0198540648681,
|
"meanAnomalyAtEpochDeg": 126.0198540648681,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "jupiter",
|
"parentBodyId": "jupiter"
|
||||||
"rotationPeriodHours": 400.52470451330635
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "titan",
|
"id": "titan",
|
||||||
@@ -303,8 +277,7 @@
|
|||||||
"meanAnomalyAtEpochDeg": 32.18862839469676,
|
"meanAnomalyAtEpochDeg": 32.18862839469676,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "saturn",
|
"parentBodyId": "saturn"
|
||||||
"rotationPeriodHours": 382.87527117206236
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "triton",
|
"id": "triton",
|
||||||
@@ -321,7 +294,6 @@
|
|||||||
"meanAnomalyAtEpochDeg": 273.1157438944629,
|
"meanAnomalyAtEpochDeg": 273.1157438944629,
|
||||||
"epochJd": 2460676.5
|
"epochJd": 2460676.5
|
||||||
},
|
},
|
||||||
"parentBodyId": "neptune",
|
"parentBodyId": "neptune"
|
||||||
"rotationPeriodHours": 141.05626027628523
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,10 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||||
import { gmForParent } from '../../src/app/shared/astro/constants';
|
|
||||||
import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler';
|
|
||||||
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||||
import { fetchHorizonsBody } from './lib/horizons';
|
import { fetchHorizonsBody } from './lib/horizons';
|
||||||
import { dataPath, ensureDataDir } from './lib/paths';
|
import { dataPath, ensureDataDir } from './lib/paths';
|
||||||
|
|
||||||
const HOURS_PER_DAY = 24;
|
|
||||||
|
|
||||||
interface BodySpec {
|
interface BodySpec {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,11 +12,6 @@ interface BodySpec {
|
|||||||
horizonsCommand: string;
|
horizonsCommand: string;
|
||||||
center: string;
|
center: string;
|
||||||
parentBodyId?: string;
|
parentBodyId?: string;
|
||||||
/**
|
|
||||||
* Obliquity to orbit, in degrees, where the Horizons page states none. Pluto's is from the IAU
|
|
||||||
* WGCCRE 2015 pole (RA 132.99, Dec -6.16), 119.6 degrees: past 90, so it turns retrograde.
|
|
||||||
*/
|
|
||||||
obliquityDeg?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
|
||||||
@@ -33,7 +24,7 @@ const BODY_SPECS: BodySpec[] = [
|
|||||||
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
|
||||||
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
|
||||||
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
|
||||||
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10', obliquityDeg: 119.6 },
|
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10' },
|
||||||
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
|
||||||
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
|
||||||
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
|
||||||
@@ -65,18 +56,6 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
|||||||
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every moon listed here is tidally locked, so its day is its orbit — as drawn, from these
|
|
||||||
// elements and the parent's mass by Kepler. Not every page says so: the Moon's gives a rate,
|
|
||||||
// the true sidereal month, 1.4% off the orbit these elements trace, so its face drifted five
|
|
||||||
// degrees an orbit; Titan's gives nothing, so it did not turn. Taking the orbit keeps one face
|
|
||||||
// towards the parent, which is what synchronous means.
|
|
||||||
const rotationPeriodHours = result.tidallyLocked || spec.kind === 'moon'
|
|
||||||
? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY
|
|
||||||
: result.rotationPeriodHours;
|
|
||||||
if (rotationPeriodHours === undefined) {
|
|
||||||
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
bodies.push({
|
bodies.push({
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
systemStarId: SUN_STAR_ID,
|
systemStarId: SUN_STAR_ID,
|
||||||
@@ -84,9 +63,7 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
|
|||||||
kind: spec.kind,
|
kind: spec.kind,
|
||||||
radiusKm: result.radiusKm ?? 0,
|
radiusKm: result.radiusKm ?? 0,
|
||||||
orbit: result.orbit,
|
orbit: result.orbit,
|
||||||
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
|
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {})
|
||||||
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
|
|
||||||
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,6 @@ export interface HorizonsQuery {
|
|||||||
export interface HorizonsResult {
|
export interface HorizonsResult {
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
orbit: OrbitalElements;
|
orbit: OrbitalElements;
|
||||||
/**
|
|
||||||
* Sidereal rotation period in hours, negative where the page gives a negative rate —
|
|
||||||
* Venus and Uranus. Absent where the page publishes none. The same pages also give an obliquity
|
|
||||||
* past 90 degrees for those two, which says the same thing again; see the renderer's spinFor.
|
|
||||||
*/
|
|
||||||
rotationPeriodHours?: number;
|
|
||||||
/** Tilt of the rotation axis from the body's own orbital plane, in degrees. */
|
|
||||||
obliquityDeg?: number;
|
|
||||||
/** The page says "Synchronous" instead of a period: its day is its orbit. */
|
|
||||||
tidallyLocked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RADIUS_PATTERNS = [
|
const RADIUS_PATTERNS = [
|
||||||
@@ -40,66 +30,10 @@ const RADIUS_PATTERNS = [
|
|||||||
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* How each page states how fast the body turns, in the order they are tried.
|
|
||||||
*
|
|
||||||
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
|
|
||||||
* signed: Venus and Uranus carry a negative one. A period in hours or days
|
|
||||||
* comes next, then the sexagesimal form the giant planets use, and finally the word most moons carry
|
|
||||||
* instead of a number, Synchronous. Not all do — the Moon's page gives a rate, Titan's nothing —
|
|
||||||
* so the caller treats every moon it lists as locked whatever its page says.
|
|
||||||
*/
|
|
||||||
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
|
|
||||||
const ROTATION_PERIOD_PATTERNS = [
|
|
||||||
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
|
|
||||||
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
|
|
||||||
];
|
|
||||||
/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */
|
|
||||||
const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i;
|
|
||||||
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
|
|
||||||
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
|
|
||||||
|
|
||||||
const HOURS_PER_DAY = 24;
|
|
||||||
const SECONDS_PER_HOUR = 3600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True where the page gives no number because the body keeps one face to its parent, so its day
|
|
||||||
* is its orbit. The period itself is then Kepler's, which the caller
|
|
||||||
* works out from the elements above and the parent's mass.
|
|
||||||
*/
|
|
||||||
export function isTidallyLocked(text: string): boolean {
|
|
||||||
return SYNCHRONOUS_PATTERN.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
|
|
||||||
export function extractRotationPeriodHours(text: string): number | undefined {
|
|
||||||
const rate = text.match(ROTATION_RATE_PATTERN);
|
|
||||||
if (rate && Number(rate[1]) !== 0) {
|
|
||||||
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
|
|
||||||
}
|
|
||||||
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
|
|
||||||
if (sexagesimal) {
|
|
||||||
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR;
|
|
||||||
}
|
|
||||||
for (const pattern of ROTATION_PERIOD_PATTERNS) {
|
|
||||||
const match = text.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
|
|
||||||
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractObliquityDeg(text: string): number | undefined {
|
|
||||||
const match = text.match(OBLIQUITY_PATTERN);
|
|
||||||
return match ? Number(match[1]) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
||||||
* orbital elements plus, when available, its mean physical radius and how it turns — all in a
|
* orbital elements plus, when available, its mean physical radius — both in a single
|
||||||
* single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
||||||
*/
|
*/
|
||||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||||
const url =
|
const url =
|
||||||
@@ -110,10 +44,7 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsR
|
|||||||
const text = await fetchTextCached(url, query.cacheKey);
|
const text = await fetchTextCached(url, query.cacheKey);
|
||||||
return {
|
return {
|
||||||
radiusKm: extractRadiusKm(text),
|
radiusKm: extractRadiusKm(text),
|
||||||
orbit: extractOrbitalElements(text),
|
orbit: extractOrbitalElements(text)
|
||||||
rotationPeriodHours: extractRotationPeriodHours(text),
|
|
||||||
obliquityDeg: extractObliquityDeg(text),
|
|
||||||
tidallyLocked: isTidallyLocked(text)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user