Author SHA1 Message Date
github-actions[bot] 822c69944c Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-09-28 11:50:04 +00:00
github-actions[bot] f2608f1172 Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-09-21 10:56:50 +00:00
17 changed files with 474 additions and 1443 deletions
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;
} }
+32 -220
View File
@@ -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,54 +85,28 @@ 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()!)" [attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
(click)=" (click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent" class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]=" [class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
bookmarks.has('star', keepableStarId()!)
? 'text-accent'
: 'text-muted hover:text-accent'
"
> >
<app-bookmark-icon <app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
class="h-3.5 w-3.5"
[kept]="bookmarks.has('star', keepableStarId()!)"
/>
</button> </button>
} }
</div> </div>
@@ -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);
} }
} }
-10
View File
@@ -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;
} }
+16 -15
View File
@@ -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. */
-74
View File
@@ -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());
});
});
-82
View File
@@ -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
View File
@@ -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
+2 -25
View File
@@ -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 } : {})
}); });
} }
+3 -72
View File
@@ -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)
}; };
} }