Author SHA1 Message Date
SenrokaiandClaude Opus 5.5 c38a42cbcb Fix what the review of this branch found, starting with the pick rule it only claimed
The off-screen rule for clicks was described in 3f0abf8 and in the pull request, but only its
comment was committed: pickAt still let the slop reach past the frame. The mutant that was said
to catch it matched nothing, and an unrelated flaky test failed instead. The frame test is now in
pickAt, before the slop, and its test fails without it (star at NDC 1.01, click at 0.995).

Venus, Uranus and Pluto turned forwards: Horizons states a retrograde spin twice, by a negative
rate and by an obliquity over 90 degrees, and both were applied. The period's sign is now used
only when no obliquity is known. Measured on the live markers, spin axis against orbit normal is
cos(obliquity) for each: Venus -0.999, Uranus -0.135, Pluto -0.494, Earth 0.917.

Moons listed as rates rather than "Synchronous" drifted about 5 degrees an orbit and Titan did not
turn: every moon is now locked at its Kepler period. Pluto's obliquity comes from IAU WGCCRE 2015,
Horizons gives none.

Also:
- the star's light is white at pi, not a warm 2.2 that left the photographs dim;
- procedural textures are 128x64, not 512x256 that froze the main thread ~60 ms a body;
- Io, Pluto, Titan and Deimos lose their "maps", which were disc photographs with black sky;
- an exoplanet with only a mass gets a radius from it (M^0.55, capped at Jupiter), not Earth's;
- the clock knows when it has left the present even once back at real time, so the date and
  "Back to now" stay up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:50:07 +02:00
SenrokaiandClaude Opus 5 468c98b14a Surface the system view with the photographs it already had, lit by its star
Every body in the system view was an unlit sphere wearing a 32 by 16 pixel
procedural texture — the size chosen when a marker was a few pixels across and
what survived was its average colour. The thirteen real photographs in
`src/assets/textures/bodies/` were used only by the detail page. So Mars was a
pale grey ball with invented polar caps while its own NASA mosaic sat unread in
the repository, and nothing had a day side or a night side.

Each marker now takes its own photograph where one exists, at the size the
detail page uses, and the derived texture only where none does — the five moons
no probe mapped, and every exoplanet, none of which has ever been imaged. The
material is lit, and the light is a point at the star, so each world shows the
terminator where it really falls.

The light does not fall off with distance. Under the inverse square that real
light obeys, Neptune receives a thousandth of what Mercury does 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. That is the same concession the pixel
floor makes for size, and it is only about brightness: the *direction* is real.

Spheres are 32 by 24 rather than 16 by 12, since at true scale a body is drawn
anywhere from a pixel to the whole frame and the old silhouette was visibly
faceted at the near end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:03:24 +02:00
SenrokaiandClaude Opus 5 4b276e44a5 Give the map a clock, so the sky it computes can be watched
The orbits and the rotations are both functions of a date, and the only date the
map ever asked for was this instant. So a view built on propagated ephemerides
showed a still picture: Earth turns 15 degrees an hour and takes a year to go
round, and a reader watching for a minute saw nothing move at all.

`TimeStore` is that date, at a rate the reader sets: real time, an hour a
second, a day a second, a month a second. It is read once a frame rather than
held in a signal — it changes continuously, and a signal changing sixty times a
second would ask the whole HUD to re-render for a number nothing is watching.
Changing the rate re-anchors rather than rewinding, so speeding up and slowing
down never jumps the sky, and "Back to now" returns to the world's own time.

Measured in the app, three seconds of watching in the Sun's system:

| rate | sky elapsed | Earth turned | Jupiter moved |
|---|---|---|---|
| real time | 0 | 0 | 0 |
| 1 h/s | 3.0 h | 45.12 deg | 0.0009 AU |
| 1 d/s | 3.0 d | (three full turns) | 0.0222 AU |

45.12 degrees in three hours is 15.04 an hour, which is Earth's own sidereal
rate, and Jupiter's 0.0222 AU in three days is its own orbital speed.

The rates are radio buttons, not toggles: they are one of four, and the native
control carries that to a screen reader and to the arrow keys with no script.
The date joins the strip only while the clock is running faster than the world,
since at real time it is today's, which the reader's machine already says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:59:28 +02:00
SenrokaiandClaude Opus 5 225d676ab0 Turn each body at its own rate, from Horizons' own figures
The view had one rotation in it — the planet on the detail page, at 0.08 rad/s,
a number with no source. Nothing in the system view turned at all.

The data was already on disk: every cached Horizons page carries how its body
spins, in one of five forms. The rate in radians per second is preferred where
it appears, because it is signed — that is how Venus and Uranus are known to
turn backwards — then a period in hours or days, then the `9h 55m 29.711 s`
the giant planets use, and finally the word every major moon here carries
instead of a number: Synchronous. A tidally locked moon's day is its orbit, so
Kepler supplies it from the elements already parsed and the parent it goes
round.

Seventeen of the eighteen bodies come out within 1% of their published period —
Earth 23.934 h, Jupiter 9.925 h, Venus -5832.5 h, Io 42.5 h, Callisto 400.5 h.
Titan is the exception: its page states no period at all, so it is left still
rather than turned at an invented rate.

The axis is the orbit normal tilted by the obliquity about the orbit's
ascending node, which is where an obliquity is measured from and the only line
in the orbit the elements name. The phase at the epoch is published for none of
these bodies, so the face turned toward the camera is not a claim; the rate and
the direction are.

At true rates nothing is visible moving — Earth turns 15 degrees an hour. A
clock the reader can run faster is the next piece, and the audit asks for it
anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-21 23:12:02 +02:00
SenrokaiandClaude Opus 5 3f0abf8717 Draw the system at true scale, drop the halo, and refuse to enter what is off screen
Three changes to what the system view claims, all of them the same claim: that
the sizes on screen mean something.

**The halo is gone.** It was a sprite sized against the arrival frame — 1.12 AU
for the Sun — so it stayed that wide as the camera closed in and ended up a flat
gradient filling the screen, over the photograph it was meant to dress. It
existed to keep the star visible at a framing that holds the whole system, which
is now handled in pixels instead.

**Bodies are drawn at their own radius.** The old marker size was exaggerated
and scaled to the system span, and clamped: Jupiter and Ganymede both ran past
the ceiling and were drawn at one radius, so every moon orbited inside its
planet, and Phobos and Triton sat entirely within Mars and Neptune. True scale
needs no rule against that — physics already puts a moon outside the planet it
orbits. What it costs is visibility at the arrival framing, where every body is
sub-pixel, so the scene floors each marker at 3 px on screen and holds a moon to
half its planet's drawn size. Measured in the Sun's system: at arrival, planets
3 px and moons 1.5 px, against 3 px for everything before; at Jupiter, the
planet 10.8 px at scale 1 with the Galilean moons on their orbits outside it.

The Sun is drawn at its own radius too. Every other star keeps a size derived
from its innermost orbit, because no stellar radius reaches the app — Gaia's
`radius_gspphot` is the obvious next fetch.

**A click cannot enter a system that is not on screen.** The picker tested depth
but not the frame, and a star's hit area is its drawn size plus a slop, so a
click in the last pixels of the view could fly into a system outside it, with
nothing on screen to explain where it had gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-21 17:26:32 +02:00
15 changed files with 1441 additions and 472 deletions
File diff suppressed because it is too large Load Diff
@@ -172,6 +172,25 @@ 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,6 +406,11 @@ 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
@@ -429,10 +434,16 @@ 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 ndcRadius = (0.5 * sizes[index]) / tanHalfFov + PICK_NDC_SLOP; const drawnRadius = (0.5 * sizes[index]) / tanHalfFov;
// 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,7 +5,6 @@ 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,
@@ -113,105 +112,6 @@ 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 } {
@@ -278,54 +178,41 @@ describe('star and framing together', () => {
describe('bodyMarkerRadiusAu', () => { describe('bodyMarkerRadiusAu', () => {
const EARTH_RADIUS_KM = 6371; const EARTH_RADIUS_KM = 6371;
const SOLAR_SPAN_AU = 30.07; const KM_PER_AU = 149597870.7;
it('scales in proportion to the system span', () => { it('draws a body at its true size', () => {
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100); expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
expect(compact / wide).toBeCloseTo(0.01, 6);
}); });
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => { it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one // Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
// planet swallowed the whole view. // Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
for (const span of [0.06, 0.204, 1, 30.07, 800]) { const jupiter = bodyMarkerRadiusAu(69911);
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5); const ganymede = bodyMarkerRadiusAu(2634);
} 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('gives compact and wide systems the same apparent marker size', () => { it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span); const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268;
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6); expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU);
}); });
it('still renders a bigger body as a bigger marker', () => { it('still renders a bigger body as a bigger marker', () => {
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
expect(jupiter).toBeGreaterThan(pluto);
}); });
it('falls back to the smallest marker for a body with no known radius', () => { it('falls back to an Earth for a body with no published radius', () => {
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU); for (const nothing of [undefined, 0, -1]) {
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
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,31 +27,6 @@ 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
@@ -150,20 +125,6 @@ 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.
@@ -223,32 +184,35 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] {
} }
/** /**
* Span of the solar system, in AU, used as the reference every other system's marker sizes are * A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
* scaled against. The marker constants below were tuned by eye at this scale. * a radius nor a mass to estimate one from is drawn as: an Earth, for want of anything better —
* exoplanets with a mass and no radius get an estimate from their mass before they reach here.
*/ */
const REFERENCE_SYSTEM_SPAN_AU = 30; const KM_PER_AU = 149597870.7;
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;
/** /**
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in. * The Sun's own radius, in AU — the one star whose size this map knows.
* *
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its * Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU * orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth * positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so * here, and until the ETL fetches it, a system's star is the one body in the view that is not
* a single planet swallows the entire view. * to scale.
*
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
* planets as small dots on them.
*/ */
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number { export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
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,3 +372,95 @@ 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,7 +3,8 @@ 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 { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { 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';
@@ -18,6 +19,8 @@ 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);
@@ -119,21 +122,110 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
} }
/** /**
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category * A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is * with a texture derived from its measurements where none has — and lit by its star either way,
* a consequence of its measurements rather than of which list it came from. * so a world shows the day and night it actually has.
* *
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what * The photographs were already in the repository, used only by the detail page: the system view
* survives is essentially its average colour, and generating it costs well under a millisecond. * drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
* 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(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh { function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12); const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
const material = appearance const photograph = id ? bodyTexturePath(id) : undefined;
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) }) // 128 by 64 for the derived texture, not the detail page's 512 by 256: that size costs about
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); // 60 ms a body on the main thread, 360 ms on entering a six-planet system, for a disc that is a
// 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;
@@ -144,6 +236,9 @@ 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 {
@@ -154,6 +249,8 @@ interface TrackedMoon {
frame: THREE.Quaternion; frame: THREE.Quaternion;
pivot: THREE.Group; pivot: THREE.Group;
parentId: string; parentId: string;
rotationPeriodHours?: number;
obliquityDeg?: number;
} }
/** /**
@@ -206,8 +303,6 @@ 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!)
@@ -227,7 +322,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)); 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 });
members.push({ id: body.id, kind, marker: tracked.marker }); members.push({ id: body.id, kind, marker: tracked.marker });
} }
@@ -240,8 +335,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)); 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 });
members.push({ id: body.id, kind: 'moon', marker: moon.marker }); members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id });
} }
// 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.
@@ -256,7 +351,8 @@ export class SystemOrbitsRenderer {
continue; continue;
} }
const elements = resolveOrbitalElements(exoplanet.orbit); const elements = resolveOrbitalElements(exoplanet.orbit);
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined; const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth);
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({
@@ -299,6 +395,9 @@ 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. */
@@ -307,6 +406,9 @@ 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) {
@@ -317,6 +419,9 @@ 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
@@ -371,15 +476,16 @@ 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(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); const marker = buildMarker(id, kind, radiusKm, 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() }; const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
this.topLevelBodies.push(tracked); this.topLevelBodies.push(tracked);
return tracked; return tracked;
} }
@@ -391,17 +497,18 @@ 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('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); const marker = buildMarker(id, 'moon', radiusKm, 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 }; const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
this.moons.push(moon); this.moons.push(moon);
return moon; return moon;
} }
+225 -37
View File
@@ -1,9 +1,27 @@
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core'; import {
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 { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component'; import {
RouteRequest,
RouteResult,
RoutesPanelComponent,
RouteStarOption,
} from './routes-panel.component';
export interface HudReadout { export interface HudReadout {
readonly label: string; readonly label: string;
@@ -31,7 +49,16 @@ export interface HudDisplay {
readonly plan: boolean; readonly plan: boolean;
} }
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false }; export const DEFAULT_HUD_DISPLAY: HudDisplay = {
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' },
@@ -41,17 +68,26 @@ 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> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' }; const TAB_LABELS: Record<DockTab, string> = {
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 = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null; const wideViewportQuery =
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;
@@ -74,7 +110,10 @@ 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: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' }, host: {
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
@@ -85,29 +124,55 @@ 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 id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"> <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"
>
<app-search (picked)="onPicked()" /> <app-search (picked)="onPicked()" />
</section> </section>
} }
@case ('readout') { @case ('readout') {
<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"> <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"
>
<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 data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p> <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]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()" [attr.aria-label]="
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)" (bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
(click)="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" [attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'" (click)="
> bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" /> "
</button> class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[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()) {
@@ -117,30 +182,54 @@ 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">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt> <dt class="type-label text-muted">
{{ 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 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> <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 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"> <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"
>
@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 (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) { @for (
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">{{ bookmark.name }}</span> <span class="min-w-0 flex-1 truncate text-sm text-text">{{
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span> bookmark.name
}}</span>
<span class="type-label shrink-0 text-muted">{{
bookmark.kind === 'star' ? 'System' : 'Body'
}}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -148,7 +237,15 @@ 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 class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"> <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"
>
<path d="M3 3l8 8M11 3l-8 8" /> <path d="M3 3l8 8M11 3l-8 8" />
</svg> </svg>
</button> </button>
@@ -157,13 +254,20 @@ 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 <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. 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.
</p> </p>
} }
</section> </section>
} }
@case ('display') { @case ('display') {
<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"> <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"
>
<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) {
@@ -172,14 +276,63 @@ 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]="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'" [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'
"
> >
<!-- 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 aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span> <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>
} }
} }
@@ -190,7 +343,13 @@ 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 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"> <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"
>
<app-routes-panel <app-routes-panel
[result]="routeResult()" [result]="routeResult()"
[pending]="routePending()" [pending]="routePending()"
@@ -215,21 +374,39 @@ 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]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'" [class]="
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 class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"> <p
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. */
@@ -241,6 +418,8 @@ 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. */
@@ -271,12 +450,14 @@ 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);
@@ -331,7 +512,10 @@ export class HudDockComponent implements OnInit {
return; return;
} }
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -343,7 +527,11 @@ 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 (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) { if (
this.activeTab() &&
!isWideViewport() &&
!this.host.nativeElement.contains(event.target as Node)
) {
this.activeTab.set(null); this.activeTab.set(null);
} }
} }
+10
View File
@@ -29,4 +29,14 @@ 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;
} }
+15 -16
View File
@@ -4,18 +4,21 @@ 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`.
* *
* This map is the whole of what has actually been photographed. Everything else — every * Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map * sphere. Everything else — every exoplanet, since not one has ever been imaged, and every moon
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's * or dwarf planet with no such map in the repository — falls through to
* own measured size, mass, orbit and host star instead. * `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
* mass, orbit and host star instead.
* *
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia * Io, Pluto, Titan and Deimos used to be listed with the square photographs of them in
* Commons — see each file's Commons page for the original credit line): * `assets/textures/bodies/`: pictures of a lit disc against black sky, not maps. Wrapped round a
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System * sphere they put black sky on a fifth to a third of the surface, in a band up to 57 degrees wide
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto — * across the equator that the spin then swept past the camera. They are left out until a real map
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona * of each is added.
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL *
* Cassini true-color view. * Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
* 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',
@@ -26,11 +29,7 @@ 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',
pluto: 'assets/textures/bodies/pluto.jpg', moon: 'assets/textures/bodies/moon.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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,82 @@
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);
}
}
+46 -18
View File
@@ -13,7 +13,9 @@
"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",
@@ -29,7 +31,9 @@
"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",
@@ -45,7 +49,9 @@
"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",
@@ -61,7 +67,9 @@
"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",
@@ -77,7 +85,9 @@
"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",
@@ -93,7 +103,9 @@
"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",
@@ -109,7 +121,9 @@
"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",
@@ -125,7 +139,9 @@
"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",
@@ -141,7 +157,9 @@
"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",
@@ -158,7 +176,9 @@
"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",
@@ -175,7 +195,8 @@
"meanAnomalyAtEpochDeg": 342.6005509941174, "meanAnomalyAtEpochDeg": 342.6005509941174,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "mars" "parentBodyId": "mars",
"rotationPeriodHours": 7.660212212136228
}, },
{ {
"id": "deimos", "id": "deimos",
@@ -192,7 +213,8 @@
"meanAnomalyAtEpochDeg": 273.8943716897566, "meanAnomalyAtEpochDeg": 273.8943716897566,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "mars" "parentBodyId": "mars",
"rotationPeriodHours": 30.304279685850094
}, },
{ {
"id": "io", "id": "io",
@@ -209,7 +231,8 @@
"meanAnomalyAtEpochDeg": 74.88524962049125, "meanAnomalyAtEpochDeg": 74.88524962049125,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "jupiter" "parentBodyId": "jupiter",
"rotationPeriodHours": 42.51537583211252
}, },
{ {
"id": "europa", "id": "europa",
@@ -226,7 +249,8 @@
"meanAnomalyAtEpochDeg": 40.72575250295771, "meanAnomalyAtEpochDeg": 40.72575250295771,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "jupiter" "parentBodyId": "jupiter",
"rotationPeriodHours": 85.27848729079142
}, },
{ {
"id": "ganymede", "id": "ganymede",
@@ -243,7 +267,8 @@
"meanAnomalyAtEpochDeg": 355.7249344187845, "meanAnomalyAtEpochDeg": 355.7249344187845,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "jupiter" "parentBodyId": "jupiter",
"rotationPeriodHours": 171.78271980469088
}, },
{ {
"id": "callisto", "id": "callisto",
@@ -260,7 +285,8 @@
"meanAnomalyAtEpochDeg": 126.0198540648681, "meanAnomalyAtEpochDeg": 126.0198540648681,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "jupiter" "parentBodyId": "jupiter",
"rotationPeriodHours": 400.52470451330635
}, },
{ {
"id": "titan", "id": "titan",
@@ -277,7 +303,8 @@
"meanAnomalyAtEpochDeg": 32.18862839469676, "meanAnomalyAtEpochDeg": 32.18862839469676,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "saturn" "parentBodyId": "saturn",
"rotationPeriodHours": 382.87527117206236
}, },
{ {
"id": "triton", "id": "triton",
@@ -294,6 +321,7 @@
"meanAnomalyAtEpochDeg": 273.1157438944629, "meanAnomalyAtEpochDeg": 273.1157438944629,
"epochJd": 2460676.5 "epochJd": 2460676.5
}, },
"parentBodyId": "neptune" "parentBodyId": "neptune",
"rotationPeriodHours": 141.05626027628523
} }
] ]
+25 -2
View File
@@ -1,10 +1,14 @@
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;
@@ -12,6 +16,11 @@ 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).
@@ -24,7 +33,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' }, { id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10', obliquityDeg: 119.6 },
{ 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' },
@@ -56,6 +65,18 @@ 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,
@@ -63,7 +84,9 @@ 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 } : {})
}); });
} }
+72 -3
View File
@@ -20,6 +20,16 @@ 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 = [
@@ -30,10 +40,66 @@ 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 — both in a single * orbital elements plus, when available, its mean physical radius and how it turns — all in a
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`). * single 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 =
@@ -44,7 +110,10 @@ 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)
}; };
} }